// ============================================
// MariusVPN — all screens
// Exposes: LoginScreen, DashboardScreen, SubscriptionScreen, PaymentsScreen,
// DevicesScreen, TrafficScreen, ReferralScreen, HelpScreen
// ============================================
const { useState, useEffect, useRef, useCallback, useMemo } = React;
const {
api, MOCK, Icon, FMT, PLATFORM_LABEL, PLATFORM_ICON,
Logo, Card, Button, IconButton, Badge, Toggle, ProgressBar, Skeleton, Spinner,
Field, Input, Select, CopyField,
Modal, Sheet, useToast, ScreenHeader, KV,
} = window;
// ============================================
// LOGIN
// ============================================
const TG_BOT_USERNAME = 'marius_vpnbot';
function LoginScreen({ onLogin }) {
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [showLink, setShowLink] = useState(false);
const widgetRef = useRef(null);
const toast = useToast();
const submit = async (e) => {
e.preventDefault();
let t = code.trim();
if (!t) { toast.error('Вставьте ссылку или код из бота'); return; }
// The bot sends a login LINK (…/cabinet/?token=XXXX). Accept either the
// whole link or just the token.
const m = t.match(/[?&]token=([^&\s]+)/);
if (m) t = decodeURIComponent(m[1]);
setLoading(true);
try {
await api('/login', { method: 'POST', body: { token: t } });
toast.success('Вход выполнен');
onLogin?.();
} catch (err) {
toast.error('Ссылка/код недействительны. Получите новые в боте.');
} finally { setLoading(false); }
};
// Fallback: ask the bot to DM a one-time browser login link.
const getLink = () => {
if (MOCK) { onLogin?.(); return; }
window.open(`https://t.me/${TG_BOT_USERNAME}?start=weblogin`, '_blank');
};
// Telegram Login Widget — official "Log in with Telegram" button (browser OAuth).
useEffect(() => {
if (MOCK) return;
window.onTelegramAuth = async (user) => {
try {
const r = await fetch('/api/tg/cabinet-widget-auth', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
if (!r.ok) throw new Error('widget auth failed');
toast.success('Вход выполнен');
onLogin?.();
} catch (e) {
toast.error('Не удалось войти. Возможно, этот Telegram ещё не регистрировался в боте.');
}
};
const el = widgetRef.current;
if (el && !el.querySelector('script')) {
const s = document.createElement('script');
s.async = true;
s.src = 'https://telegram.org/js/telegram-widget.js?22';
s.setAttribute('data-telegram-login', TG_BOT_USERNAME);
s.setAttribute('data-size', 'large');
s.setAttribute('data-radius', '12');
s.setAttribute('data-onauth', 'onTelegramAuth(user)');
s.setAttribute('data-request-access', 'write');
el.appendChild(s);
}
}, []);
// Try to auto-auth via Telegram WebApp initData if opened inside Telegram.
useEffect(() => {
const tgInitData = window.Telegram?.WebApp?.initData;
if (!tgInitData) return;
(async () => {
try {
const r = await fetch('/api/tg/cabinet-auth', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ init_data: tgInitData }),
});
if (r.ok) { onLogin?.(); }
} catch (e) {}
})();
}, []);
return (
Личный кабинет
Войдите через Telegram — в один клик, на любом устройстве.
{/* Primary: official Telegram Login Widget button */}
{/* Secondary: bot DMs a one-time browser link */}
{/* Tertiary: paste a link manually */}
{showLink && (
)}
Входя, вы соглашаетесь с условиями использования и политикой конфиденциальности.
);
}
// ============================================
// DASHBOARD
// ============================================
function DashboardScreen({ onRoute, refreshKey }) {
const [me, setMe] = useState(null);
const [traffic, setTraffic] = useState(null);
const toast = useToast();
useEffect(() => {
let alive = true;
Promise.all([api('/me'), api('/traffic')]).then(([m, t]) => {
if (!alive) return;
setMe(m); setTraffic(t);
});
return () => { alive = false; };
}, [refreshKey]);
if (!me || !traffic) return ;
const s = me.subscription;
const lim = me.limits;
const bypass = traffic.bypass;
const bypassPct = (bypass.used_gb / bypass.quota_gb) * 100;
return (
{/* Subscription status (hero) */}
Подписка
{s.plan_name}
{s.status === 'active' && Активна}
{s.status === 'expired' && Истекла}
{s.status === 'trial' && Триал}
{s.days_left}
{FMT.daysWord(s.days_left)} до окончания
истекает {FMT.date(s.expires_at)}
оплачено {s.period_label}
{Math.round((s.days_left / s.total_days) * 100)}%
{/* Traffic card */}
Трафик
{FMT.gb(traffic.total.down_gb)}
скачано
↓ {FMT.gb(traffic.total.down_gb)}
·
↑ {FMT.gb(traffic.total.up_gb)}
onRoute('traffic')} />
Сервер обхода
{FMT.gb(bypass.used_gb)} / {FMT.gb(bypass.quota_gb)}
базовый лимит {FMT.gb(bypass.base_cap_gb)} · докуплено {FMT.gb(bypass.extra_gb)}
{/* Devices card */}
Устройства
{lim.devices_used}
из {lim.devices_max} активных
доп. устройство — {FMT.money(lim.extra_device_price, lim.extra_device_currency)}/мес
onRoute('devices')} />
{traffic.devices.slice(0, 3).map(d => (
{d.name}
))}
{/* Quick links */}
{[
{ icon: 'card', title: 'Оплата', sub: 'Пополнение и история', route: 'payments' },
{ icon: 'gift', title: 'Рефералы', sub: '+30 дней за друга', route: 'referral', badge: 'gold' },
{ icon: 'globe', title: 'Локации', sub: '12 стран · 87 серверов', route: null },
{ icon: 'help', title: 'Помощь', sub: 'Инструкции и поддержка', route: 'help' },
].map((q, i) => (
))}
);
}
function DashboardSkeleton() {
return (
);
}
// ============================================
// SUBSCRIPTION
// ============================================
function SubscriptionScreen({ onRoute, onRefresh }) {
const [me, setMe] = useState(null);
const [plans, setPlans] = useState(null);
const [selected, setSelected] = useState(null);
const [busy, setBusy] = useState(false);
const toast = useToast();
useEffect(() => {
Promise.all([api('/me'), api('/plans')]).then(([m, p]) => {
setMe(m); setPlans(p);
setSelected(m.subscription.plan_id);
});
}, []);
if (!me || !plans) return ;
const s = me.subscription;
const setAutoRenew = async (v) => {
setMe({ ...me, subscription: { ...s, auto_renew: v } });
try {
await api('/subscription/auto-renew', { method: 'POST', body: { enabled: v } });
toast.success(v ? 'Автопродление включено' : 'Автопродление отключено');
} catch { toast.error('Не удалось изменить настройку'); }
};
const renew = async () => {
if (!selected) return;
const plan = plans.find(p => p.id === selected);
setBusy(true);
try {
const res = await api('/subscription/renew', { method: 'POST', body: { plan_id: selected, period_months: plan.period_months } });
if (res.payment_url) {
toast.info('Открываю страницу оплаты…');
window.location.href = res.payment_url;
return;
} else if (res.balance_ok) {
toast.success('Подписка продлена с баланса');
onRefresh?.();
}
} catch { toast.error('Не удалось оформить продление'); }
setBusy(false);
};
return (
{/* Current */}
Текущий тариф
{s.plan_name} · {s.period_label}
{FMT.money(s.price_per_month, s.currency)}/мес · истекает {FMT.date(s.expires_at)}
{s.status === 'expired'
?
Истекла
:
{s.status === 'trial' ? 'Пробный' : 'Активна'}}
Автопродление
Подписка будет продлеваться автоматически за {FMT.money(s.price_per_month, s.currency)}/мес
{/* Plans */}
Сменить тариф или продлить
{plans.map(p => {
const isCurrent = p.id === s.plan_id;
const isSelected = p.id === selected;
return (
);
})}
Безопасная оплата · можно отменить в любой момент
);
}
// ============================================
// PAYMENTS — top-up + history
// ============================================
function PaymentsScreen() {
const [methods, setMethods] = useState(null);
const [history, setHistory] = useState(null);
const [amount, setAmount] = useState(500);
const [method, setMethod] = useState(null);
const [busy, setBusy] = useState(false);
const toast = useToast();
useEffect(() => {
Promise.all([api('/payments/methods'), api('/payments/history')]).then(([m, h]) => {
setMethods(m); setHistory(h);
const first = m.find(x => x.enabled);
if (first) setMethod(first.id);
});
}, []);
if (!methods || !history) return ;
const topup = async () => {
if (!amount || amount < 50) { toast.error('Минимальная сумма — 50 ₽'); return; }
if (!method) { toast.error('Выберите способ оплаты'); return; }
setBusy(true);
try {
const res = await api('/payments/topup', { method: 'POST', body: { amount: +amount, method } });
if (res.payment_url) {
toast.info('Открываю страницу оплаты…');
window.location.href = res.payment_url;
return;
}
} catch { toast.error('Ошибка оплаты'); }
setBusy(false);
};
const statusBadge = (s) => {
if (s === 'paid') return оплачено;
if (s === 'pending') return ожидание;
if (s === 'cancelled') return отменён;
return {s};
};
return (
{/* Top-up form */}
Пополнение баланса
setAmount(e.target.value)}
/>
₽
{[500, 1000, 2000, 5000].map(v => (
))}
{methods.filter(m => m.enabled).map(m => (
))}
{methods.filter(m => !m.enabled).map(m => (
))}
{/* History */}
История платежей
{history.map(h => (
{h.description}
{FMT.dateShort(h.date)} · {h.method}
{FMT.money(h.amount, h.currency)}
{statusBadge(h.status)}
))}
);
}
// ============================================
// DEVICES
// ============================================
function DevicesScreen({ onRefresh }) {
const [me, setMe] = useState(null);
const [devices, setDevices] = useState(null);
const [showAdd, setShowAdd] = useState(false);
const [showConfig, setShowConfig] = useState(null); // device id
const [renaming, setRenaming] = useState(null); // device obj
const [deleting, setDeleting] = useState(null); // device obj
const [syncing, setSyncing] = useState(false);
const [repairing, setRepairing] = useState(false);
const toast = useToast();
const repairVpn = async () => {
setRepairing(true);
try {
const r = await api('/repair', { method: 'POST' });
if (r && r.ok) {
toast.success('Готово! Ключи обновлены на ' + r.nodes + ' серверах. Обновите подписку в приложении, выберите сервер «' + r.recommended + '» и переподключитесь.');
onRefresh && onRefresh();
} else {
toast.error((r && r.message) || 'Не удалось починить автоматически');
}
} catch (e) {
toast.error('Ошибка при починке, попробуйте ещё раз');
} finally {
setRepairing(false);
}
};
const syncDevices = async () => {
setSyncing(true);
try { await api('/sync', { method: 'POST' }); toast.success('VPN синхронизирован на всех серверах. Обновите конфиг в приложении.'); onRefresh?.(); }
catch { toast.error('Ошибка синхронизации'); }
finally { setSyncing(false); }
};
const reload = async () => {
const [m, d] = await Promise.all([api('/me'), api('/devices')]);
setMe(m); setDevices(d);
};
useEffect(() => { reload(); }, []);
if (!me || !devices) return ;
const lim = me.limits;
const atLimit = lim.devices_used >= lim.devices_max;
const addDevice = async (name, platform) => {
const res = await api('/devices', { method: 'POST', body: { name, platform } });
await reload(); onRefresh?.();
toast.success('Устройство добавлено');
setShowAdd(false);
setShowConfig(res.device.id);
};
const renameDevice = async (id, name) => {
await api(`/devices/${id}`, { method: 'PATCH', body: { name } });
await reload();
toast.success('Переименовано');
setRenaming(null);
};
const removeDevice = async (id) => {
await api(`/devices/${id}`, { method: 'DELETE' });
await reload(); onRefresh?.();
toast.success('Устройство удалено');
setDeleting(null);
};
return (
}
/>
{atLimit && (
Достигнут лимит устройств
Удалите неиспользуемое устройство или подключите дополнительное за {FMT.money(lim.extra_device_price, lim.extra_device_currency)}/мес.
)}
{devices.map(d => (
{d.name}
{d.online
? online
: offline}
{PLATFORM_LABEL[d.platform]} · HWID {d.hwid} · {d.last_activity}
setShowConfig(d.id)} />
setRenaming(d)} />
setDeleting(d)} />
))}
{devices.length === 0 && (
Нет подключённых устройств
Добавьте устройство, чтобы получить конфигурацию.
)}
setShowAdd(false)} onSubmit={addDevice} />
d.id === showConfig)}
onClose={() => setShowConfig(null)}
/>
setRenaming(null)}
onSubmit={(name) => renameDevice(renaming.id, name)}
/>
removeDevice(deleting.id)}
onClose={() => setDeleting(null)}
/>
);
}
function AddDeviceModal({ open, onClose, onSubmit }) {
const [name, setName] = useState('');
const [platform, setPlatform] = useState('ios');
const [loading, setLoading] = useState(false);
useEffect(() => { if (open) { setName(''); setPlatform('ios'); } }, [open]);
const submit = async (e) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
await onSubmit(name.trim(), platform);
setLoading(false);
};
return (
);
}
// QR generator (offline, uses qrcode-generator if available, otherwise falls back to qrserver)
function QrCode({ data }) {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
if (typeof window.qrcode === 'function') {
try {
const qr = window.qrcode(0, 'M');
qr.addData(data);
qr.make();
// createSvgTag returns inline SVG; expand it to fill container
const svg = qr.createSvgTag({ cellSize: 4, margin: 4, scalable: true });
ref.current.innerHTML = svg.replace('`;
}
}, [data]);
return ;
}
function ConfigSheet({ open, deviceId, device, onClose }) {
const [cfg, setCfg] = useState(null);
useEffect(() => {
if (!open || !deviceId) { setCfg(null); return; }
api(`/devices/${deviceId}/config`).then(setCfg);
}, [open, deviceId]);
return (
{!cfg ? : (
QR-код
Отсканируйте код в клиенте Happ, v2RayTun, Hiddify — подписка импортируется автоматически.
)}
);
}
function RenameModal({ open, device, onClose, onSubmit }) {
const [name, setName] = useState('');
useEffect(() => { if (open && device) setName(device.name); }, [open, device]);
const submit = (e) => { e.preventDefault(); onSubmit(name.trim()); };
return (
);
}
function ConfirmModal({ open, title, message, confirmLabel = 'Подтвердить', onConfirm, onClose }) {
return (
{message}
);
}
// ============================================
// TRAFFIC
// ============================================
function TrafficScreen({ onRefresh }) {
const [traffic, setTraffic] = useState(null);
const [showBuy, setShowBuy] = useState(false);
const toast = useToast();
const reload = () => api('/traffic').then(setTraffic);
useEffect(() => { reload(); }, []);
if (!traffic) return ;
const b = traffic.bypass;
const pct = (b.used_gb / b.quota_gb) * 100;
const buy = async (gb) => {
await api('/traffic/buy-gb', { method: 'POST', body: { gb } });
await reload(); onRefresh?.();
toast.success(`Докуплено ${gb} ГБ`);
setShowBuy(false);
};
return (
{/* Total */}
Общий трафик
скачано
{FMT.gb(traffic.total.down_gb)}
отправлено
{FMT.gb(traffic.total.up_gb)}
{/* Bypass */}
Сервер обхода
{FMT.gb(b.used_gb)}
из {FMT.gb(b.quota_gb)}
базовый лимит
{FMT.gb(b.base_cap_gb)}
докуплено
{FMT.gb(b.extra_gb)}
{/* Per-device breakdown */}
По устройствам
{traffic.devices.map(d => {
const total = d.up_gb + d.down_gb;
const max = Math.max(...traffic.devices.map(x => x.up_gb + x.down_gb));
return (
{d.name}
↓{FMT.gb(d.down_gb)}
·
↑{FMT.gb(d.up_gb)}
);
})}
setShowBuy(false)} bypass={b} onBuy={buy} />
);
}
function BuyGbModal({ open, onClose, bypass, onBuy }) {
const packs = [
{ gb: 10, price: 10 * bypass.price_per_gb },
{ gb: 25, price: 25 * bypass.price_per_gb - 50 },
{ gb: 50, price: 50 * bypass.price_per_gb - 150 },
{ gb: 100, price: 100 * bypass.price_per_gb - 400 },
];
const [pick, setPick] = useState(25);
const [loading, setLoading] = useState(false);
return (
Пакет добавится к лимиту сервера обхода и сохранится до конца расчётного периода.
{packs.map(p => (
))}
);
}
// ============================================
// REFERRAL
// ============================================
function ReferralScreen() {
const [ref, setRef] = useState(null);
const toast = useToast();
useEffect(() => { api('/referral').then(setRef); }, []);
if (!ref) return ;
const share = (platform) => {
const text = `Быстрый и удобный VPN. Регистрируйся по моей ссылке и получи первый месяц в подарок: ${ref.link}`;
if (platform === 'tg') window.open(`https://t.me/share/url?url=${encodeURIComponent(ref.link)}&text=${encodeURIComponent(text)}`);
if (platform === 'wa') window.open(`https://wa.me/?text=${encodeURIComponent(text)}`);
};
return (
Подарите другу VPN
и получите +30 дней
{ref.rule_text}
Приглашено
{ref.invited_count}
Оплатили
{ref.paid_count}
Бонус
+{ref.balance}
{ref.balance_unit}
Ваша реферальная ссылка
код: {ref.code}
Поделиться
);
}
// ============================================
// HELP
// ============================================
function HelpScreen() {
const [help, setHelp] = useState(null);
const [tab, setTab] = useState('ios');
const [openFaq, setOpenFaq] = useState(null);
useEffect(() => { api('/help').then(setHelp); }, []);
if (!help) return ;
const platform = help.platforms.find(p => p.id === tab) || help.platforms[0];
return (
Написать в поддержку
}
/>
{help.platforms.map(p => (
))}
{platform.steps.map(step => (
-
{String(step.n).padStart(2, '0')}
{step.text}
))}
Частые вопросы
{help.faq.map((f, i) => {
const isOpen = openFaq === i;
return (
{isOpen && (
{f.a}
)}
);
})}
);
}
// expose
Object.assign(window, {
LoginScreen, DashboardScreen, SubscriptionScreen, PaymentsScreen,
DevicesScreen, TrafficScreen, ReferralScreen, HelpScreen,
});