// ============================================
// MariusVPN — api helper, mock data, icons
// Exposes on window: api, MOCK, ICON, FMT
// ============================================
const API_BASE = "/api/cabinet";
// Real backend (served by FastAPI). Flip to true only for standalone
// design preview without a backend.
const MOCK = false;
// CSRF
function getCsrf() { return document.cookie.match(/csrf=([^;]+)/)?.[1] || ''; }
// ============================================
// MOCK DATA (mutable so writes feel realistic)
// ============================================
const M = {
me: {
user: { id: 'u_47102', name: 'Алексей Кузнецов', telegram: '@alexei_k', initials: 'АК' },
subscription: {
status: 'active', // 'active' | 'expired' | 'trial'
plan_id: 'pro_12m',
plan_name: 'PRO',
period_label: '12 месяцев',
price_per_month: 249,
currency: '₽',
started_at: '2026-01-17',
expires_at: '2027-01-17',
days_left: 247,
total_days: 365,
auto_renew: true,
},
limits: {
devices_used: 3,
devices_max: 5,
extra_device_price: 99,
extra_device_currency: '₽',
},
balance: { amount: 320, currency: '₽' },
},
traffic: {
total: { up_gb: 28.4, down_gb: 142.7 },
bypass: { used_gb: 87.3, quota_gb: 150, base_cap_gb: 100, extra_gb: 50, price_per_gb: 19, currency: '₽' },
devices: [
{ id: 'd1', name: 'iPhone 15 Pro', up_gb: 12.1, down_gb: 88.4 },
{ id: 'd2', name: 'MacBook Pro 14"', up_gb: 14.2, down_gb: 48.6 },
{ id: 'd3', name: 'iPad Air', up_gb: 2.1, down_gb: 5.7 },
],
},
plans: [
{ id: 'pro_1m', name: '1 месяц', period_months: 1, price: 390, per_month: 390, currency: '₽', popular: false, discount_pct: 0 },
{ id: 'pro_6m', name: '6 месяцев', period_months: 6, price: 1890, per_month: 315, currency: '₽', popular: false, discount_pct: 19 },
{ id: 'pro_12m', name: '12 месяцев', period_months: 12, price: 2990, per_month: 249, currency: '₽', popular: true, discount_pct: 36 },
{ id: 'pro_24m', name: '24 месяца', period_months: 24, price: 4790, per_month: 199, currency: '₽', popular: false, discount_pct: 49 },
],
payment_methods: [
{ id: 'card', name: 'Банковская карта', description: 'Visa, Mastercard, МИР', enabled: true, icon: 'card' },
{ id: 'sbp', name: 'СБП', description: 'Через любой банк РФ', enabled: true, icon: 'sbp' },
{ id: 'crypto', name: 'Криптовалюта', description: 'USDT, BTC, ETH', enabled: true, icon: 'crypto' },
{ id: 'apple', name: 'Apple Pay', description: 'Временно недоступно', enabled: false, icon: 'apple' },
],
payments_history: [
{ id: 'p_2026_01', date: '2026-01-17', amount: 2990, currency: '₽', method: 'Карта •• 4421', status: 'paid', description: 'Подписка PRO · 12 месяцев' },
{ id: 'p_2025_12', date: '2025-12-17', amount: 390, currency: '₽', method: 'СБП', status: 'paid', description: 'Подписка PRO · 1 месяц' },
{ id: 'p_2025_11', date: '2025-11-22', amount: 950, currency: '₽', method: 'Карта •• 4421', status: 'pending', description: 'Докупка трафика · 50 ГБ' },
{ id: 'p_2025_10', date: '2025-10-17', amount: 390, currency: '₽', method: 'Карта •• 4421', status: 'paid', description: 'Подписка PRO · 1 месяц' },
{ id: 'p_2025_09', date: '2025-09-30', amount: 390, currency: '₽', method: 'Карта •• 4421', status: 'cancelled', description: 'Подписка PRO · 1 месяц' },
],
devices: [
{ id: 'd1', name: 'iPhone 15 Pro', platform: 'ios', hwid: 'A7B3-4D2F-9E1C', online: true, last_activity: 'сейчас' },
{ id: 'd2', name: 'MacBook Pro 14"', platform: 'macos', hwid: '8C4E-1F7A-3B2D', online: true, last_activity: '12 минут назад' },
{ id: 'd3', name: 'iPad Air', platform: 'ios', hwid: '2D9F-6E1B-4A8C', online: false, last_activity: 'вчера, 21:14' },
],
referral: {
code: 'ALEXEIK7M',
link: 'https://mariusvpn.com/r/ALEXEIK7M',
invited_count: 3,
paid_count: 2,
balance: 90,
balance_unit: 'дней',
rule_text: 'Пригласите друга по своей ссылке. Когда он оплатит первый месяц, вы получите +30 дней к подписке, а друг — первый месяц бесплатно. Бонус начисляется автоматически, ограничений по количеству приглашений нет.',
},
help: {
platforms: [
{ id: 'ios', name: 'iPhone / iPad', steps: [
{ n: 1, text: 'Скачайте Happ или v2RayTun в App Store — это бесплатно.' },
{ n: 2, text: 'Откройте раздел «Устройства» в личном кабинете и нажмите «Добавить устройство».' },
{ n: 3, text: 'Скопируйте ссылку-подписку или отсканируйте QR-код в приложении.' },
{ n: 4, text: 'Выберите любой сервер из списка и нажмите «Подключить».' },
]},
{ id: 'android', name: 'Android', steps: [
{ n: 1, text: 'Установите v2RayTun, Hiddify или Happ из Google Play.' },
{ n: 2, text: 'Откройте раздел «Устройства» и нажмите «Добавить устройство».' },
{ n: 3, text: 'Вставьте ссылку-подписку в приложение или импортируйте через QR.' },
{ n: 4, text: 'Выберите сервер и нажмите «Подключить».' },
]},
{ id: 'windows', name: 'Windows', steps: [
{ n: 1, text: 'Скачайте Hiddify-Next или Nekobox с официального сайта.' },
{ n: 2, text: 'В личном кабинете создайте устройство и скопируйте ссылку-подписку.' },
{ n: 3, text: 'Вставьте ссылку в Hiddify и нажмите «Добавить».' },
{ n: 4, text: 'Выберите сервер и подключитесь.' },
]},
{ id: 'macos', name: 'macOS', steps: [
{ n: 1, text: 'Установите Streisand или Hiddify-Next через App Store или официальный сайт.' },
{ n: 2, text: 'Добавьте устройство в кабинете и получите ссылку-подписку.' },
{ n: 3, text: 'Импортируйте подписку в клиент (Subscription → Add).' },
{ n: 4, text: 'Выберите сервер и нажмите «Подключить».' },
]},
],
faq: [
{ q: 'Что делать, если VPN не подключается?', a: 'Проверьте, что устройство активно в кабинете и подписка не истекла. Попробуйте сменить сервер в клиенте, переустановить конфиг. Если не помогло — напишите в поддержку.' },
{ q: 'Сколько устройств можно подключить?', a: 'На тарифе PRO — до 5 устройств. Каждое дополнительное устройство стоит 99 ₽/месяц.' },
{ q: 'Как отвязать устройство?', a: 'В разделе «Устройства» нажмите на нужное устройство и выберите «Удалить». Конфиг будет аннулирован сразу.' },
{ q: 'Можно ли вернуть деньги?', a: 'Возврат возможен в течение 7 дней после первой оплаты при условии, что использовано менее 5 ГБ трафика. Напишите в поддержку для оформления.' },
{ q: 'Что такое «сервер обхода»?', a: 'Это специальная категория серверов для регионов с блокировками. У них отдельный лимит трафика; превышение можно докупить пакетами.' },
],
support_url: 'https://t.me/marius_support',
},
};
// fake VLESS config + QR
function fakeConfigFor(device) {
const vless = `vless://${device.id}-uuid-mock@de-fra-01.example.com:443?encryption=none&security=reality&type=tcp&sni=cloudflare.com&pbk=fake_pub_key&fp=chrome#${encodeURIComponent('VPN — ' + device.name)}`;
const sub_url = `https://mariusvpn.com/sub/${device.id}-${device.hwid.replace(/-/g, '').toLowerCase()}`;
return { vless, sub_url, qr_data: sub_url };
}
// ============================================
// MOCK RESOLVER
// ============================================
function mockResolve(path, opts = {}) {
const method = opts.method || 'GET';
const body = opts.body || {};
// GET endpoints
if (method === 'GET') {
if (path === '/me') return clone(M.me);
if (path === '/traffic') return clone(M.traffic);
if (path === '/plans') return clone(M.plans);
if (path === '/payments/methods') return clone(M.payment_methods);
if (path === '/payments/history') return clone(M.payments_history);
if (path === '/devices') return clone(M.devices);
if (path === '/referral') return clone(M.referral);
if (path === '/help') return clone(M.help);
// GET /devices/{id}/config
const cfg = path.match(/^\/devices\/([^/]+)\/config$/);
if (cfg) {
const dev = M.devices.find(d => d.id === cfg[1]);
if (!dev) throw new Error('device not found');
return fakeConfigFor(dev);
}
}
// POST /login
if (method === 'POST' && path === '/login') {
return { ok: true, user: clone(M.me.user) };
}
// POST /subscription/auto-renew
if (method === 'POST' && path === '/subscription/auto-renew') {
M.me.subscription.auto_renew = !!body.enabled;
return { ok: true, auto_renew: M.me.subscription.auto_renew };
}
// POST /subscription/renew
if (method === 'POST' && path === '/subscription/renew') {
return { ok: true, payment_url: 'https://pay.mariusvpn.com/mock?order=ord_' + Date.now() };
}
// POST /payments/topup
if (method === 'POST' && path === '/payments/topup') {
return { ok: true, payment_url: 'https://pay.mariusvpn.com/mock?order=top_' + Date.now() };
}
// POST /traffic/buy-gb
if (method === 'POST' && path === '/traffic/buy-gb') {
M.traffic.bypass.extra_gb += body.gb || 0;
M.traffic.bypass.quota_gb = M.traffic.bypass.base_cap_gb + M.traffic.bypass.extra_gb;
return { ok: true, quota_gb: M.traffic.bypass.quota_gb };
}
// POST /devices
if (method === 'POST' && path === '/devices') {
const id = 'd' + (M.devices.length + 1 + Math.floor(Math.random() * 1000));
const hwid = Array.from({ length: 3 }, () => Math.random().toString(16).slice(2, 6).toUpperCase()).join('-');
const dev = { id, name: body.name || 'Новое устройство', platform: body.platform || 'ios', hwid, online: false, last_activity: 'только что' };
M.devices.push(dev);
M.me.limits.devices_used = M.devices.length;
return { ok: true, device: clone(dev), config: fakeConfigFor(dev) };
}
// PATCH /devices/{id}
const patchDev = path.match(/^\/devices\/([^/]+)$/);
if (method === 'PATCH' && patchDev) {
const dev = M.devices.find(d => d.id === patchDev[1]);
if (!dev) throw new Error('device not found');
Object.assign(dev, body);
return { ok: true, device: clone(dev) };
}
// DELETE /devices/{id}
if (method === 'DELETE' && patchDev) {
M.devices = M.devices.filter(d => d.id !== patchDev[1]);
M.me.limits.devices_used = M.devices.length;
return { ok: true };
}
console.warn('MOCK: no handler for', method, path);
return { ok: false, error: 'unhandled' };
}
function clone(x) { return JSON.parse(JSON.stringify(x)); }
// ============================================
// API
// ============================================
async function api(path, opts = {}) {
if (MOCK) {
await new Promise(r => setTimeout(r, 180 + Math.random() * 220));
return mockResolve(path, opts);
}
const headers = { 'Content-Type': 'application/json', 'X-CSRF': getCsrf(), ...(opts.headers || {}) };
const init = { method: opts.method || 'GET', credentials: 'include', headers };
if (opts.body) init.body = JSON.stringify(opts.body);
const res = await fetch(API_BASE + path, init);
if (!res.ok) throw new Error(`API ${path} → ${res.status}`);
return res.json();
}
// ============================================
// FORMATTERS
// ============================================
const FMT = {
money: (amount, currency = '₽') => `${new Intl.NumberFormat('ru-RU').format(amount)} ${currency}`,
gb: (n) => `${(+n).toFixed(1).replace(/\.0$/, '')} ГБ`,
date: (iso) => {
const d = new Date(iso);
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
},
dateShort: (iso) => {
const d = new Date(iso);
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: '2-digit' });
},
declOf: (n, forms) => {
const a = Math.abs(n) % 100, b = a % 10;
if (a > 10 && a < 20) return forms[2];
if (b > 1 && b < 5) return forms[1];
if (b === 1) return forms[0];
return forms[2];
},
daysWord: (n) => FMT.declOf(n, ['день', 'дня', 'дней']),
};
// ============================================
// ICONS (lucide-style, 24×24, stroke 1.7)
// ============================================
const Icon = ({ name, size = 20, className = '', strokeWidth = 1.7 }) => {
const paths = ICON_PATHS[name];
if (!paths) return null;
return (
);
};
const ICON_PATHS = {
home: <>>,
shield: <>>,
shieldCheck: <>>,
crown: <>>,
device: <>>,
chart: <>>,
card: <>>,
users: <>>,
help: <>>,
menu: <>>,
more: <>>,
close: <>>,
chevR: <>>,
chevD: <>>,
chevU: <>>,
copy: <>>,
qr: <>>,
check: <>>,
plus: <>>,
download: <>>,
trash: <>>,
edit: <>>,
bell: <>>,
link: <>>,
telegram: <>>,
arrowUp: <>>,
arrowDown: <>>,
arrowRight: <>>,
zap: <>>,
gift: <>>,
globe: <>>,
settings: <>>,
logout: <>>,
refresh: <>>,
// platforms
apple: <>>,
android: <>>,
windows: <>>,
linux: <>>,
};
// platform → human + icon name
const PLATFORM_LABEL = { ios: 'iOS', android: 'Android', windows: 'Windows', macos: 'macOS', linux: 'Linux' };
const PLATFORM_ICON = { ios: 'apple', android: 'android', windows: 'windows', macos: 'apple', linux: 'linux' };
Object.assign(window, { api, MOCK, Icon, ICON_PATHS, FMT, PLATFORM_LABEL, PLATFORM_ICON, getCsrf });