// ======================================================================= // MariusVPN Franchise — Data layer (mock + real API). // Spec endpoints exactly. MOCK=true returns realistic test data scoped // to a single franchise (server is supposed to scope on its side). // ======================================================================= const MOCK = false; // real backend; set true only for standalone preview const API_BASE = "/api/franchise"; // Mock-side toggles for permission demonstration (owner controls these // from the admin panel; here we just simulate the response). const FR_PERMS = /*EDITMODE-BEGIN*/{ "allow_grant_days": true, "allow_promo": false, "username": "speedvpn", "password": "demo" }/*EDITMODE-END*/; async function frApi(path, { method = 'GET', body } = {}) { if (MOCK) return mockFrApi(path, method, body); const res = await fetch(API_BASE + path, { method, credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }); if (res.status === 401) { const e = new Error('unauthorized'); e.code = 401; throw e; } if (res.status === 403) { const e = new Error('forbidden'); e.code = 403; throw e; } if (!res.ok) { const e = new Error('http ' + res.status); e.code = res.status; throw e; } const ct = res.headers.get('content-type') || ''; return ct.includes('application/json') ? res.json() : res.text(); } // --------------- Helpers -------------------------------------------------- const RUBfr = (n) => new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' ₽'; const NUMfr = (n) => new Intl.NumberFormat('ru-RU').format(n); const DATEfr = (iso) => new Date(iso).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' }); const DATETIMEfr = (iso) => new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); function daysAheadISOfr(d) { const dt = new Date(); dt.setDate(dt.getDate() + d); return dt.toISOString(); } function daysAgoISOfr(d) { const dt = new Date(); dt.setDate(dt.getDate() - d); return dt.toISOString(); } // --------------- Mock store ---------------------------------------------- let FR_SESSION = { authed: false, username: null }; const FR_FIRSTS = ['Алексей','Дмитрий','Иван','Сергей','Андрей','Михаил','Артём','Никита','Павел','Роман','Анна','Мария','Екатерина','Юлия','Ольга','Татьяна','Полина','Виктория','Дарья','Алиса']; const FR_LASTS = ['Иванов','Петров','Соколов','Смирнов','Кузнецов','Новиков','Морозов','Волков','Лебедев','Орлов']; const FR_USERNAMES = ['marius','crypto_kid','dnyper','sokolova','volk7','andreyk','katya_p','romka','niki_t','art_kov','olga99','dasha_x','polina_b','vika__','tatyana_l','m_serg','ivan_iv','pasha_r','nik_b','alina_z']; function frSeed(seed) { let s = seed; return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; } const frRnd = frSeed(73); const frPick = (arr) => arr[Math.floor(frRnd() * arr.length)]; const FR_USERS = (() => { const arr = []; for (let i = 0; i < 84; i++) { const fn = frPick(FR_FIRSTS), ln = frPick(FR_LASTS); const expIn = Math.floor(frRnd() * 60) - 8; arr.push({ id: 200000 + i, tg_id: 1100000000 + Math.floor(frRnd() * 800000000), tg_username: frPick(FR_USERNAMES) + (frRnd() < 0.5 ? '' : String(Math.floor(frRnd()*99))), tg_name: `${fn} ${ln[0]}.`, balance: Math.floor(frRnd() * 1200), days_left: expIn, expire_at: daysAheadISOfr(expIn), topups_count: Math.floor(frRnd() * 12), is_blocked: frRnd() < 0.04, }); } return arr; })(); const frUserStatus = (u) => u.is_blocked ? 'blocked' : (u.days_left <= 0 ? 'expired' : 'active'); const FR_BROADCAST_JOBS = [ { id: 312, status: 'sent', sent: 78, failed: 1, created_at: daysAgoISOfr(0) }, { id: 311, status: 'sending', sent: 41, failed: 0, created_at: daysAgoISOfr(0) }, { id: 310, status: 'sent', sent: 80, failed: 2, created_at: daysAgoISOfr(2) }, { id: 309, status: 'sent', sent: 74, failed: 0, created_at: daysAgoISOfr(6) }, { id: 308, status: 'failed', sent: 0, failed: 0, created_at: daysAgoISOfr(8) }, ]; const FR_WITHDRAWALS = [ { id: 24, amount: 8000, status: 'paid', details: 'Сбер ····4421', created_at: daysAgoISOfr(5) }, { id: 23, amount: 12000, status: 'paid', details: 'TON UQDx…42pq', created_at: daysAgoISOfr(18) }, { id: 22, amount: 5400, status: 'rejected', details: 'Тинькофф ····2210', created_at: daysAgoISOfr(26) }, { id: 21, amount: 9200, status: 'paid', details: 'Сбер ····4421', created_at: daysAgoISOfr(33) }, ]; const FR_SETTINGS = { price: 249, min_price: 199, brand_color: '#22D3EE', welcome_text: 'Добро пожаловать в SpeedVPN!\nБыстро, надёжно, без логов. 3 дня пробного периода — бесплатно.', banners: [ { id: 1, title: 'Новогодняя скидка', body: '−30% на год при оплате до 31.12', active: true }, { id: 2, title: 'Реферальная программа', body: 'Приведи друга — получи 7 дней', active: true }, { id: 3, title: 'Stories-бонус', body: 'Отметь нас в Stories — +3 дня', active: false }, ], required_channel_username: '@speedvpn_news', review_url: 'https://t.me/c/123456/9', review_text: 'Спасибо! Если нравится сервис — оставьте, пожалуйста, отзыв в нашем канале.', stories_bonus_enabled: true, }; const FR_DASH = () => { const users = FR_USERS.length; const active = FR_USERS.filter(u => frUserStatus(u) === 'active').length; const revenue = 142800; // за всё время для франшизы const revenue_month = 38400; const balance = 14820; // доступно к выводу const payouts_total = 89000; const share_percent = 50; return { users, users_active: active, revenue, revenue_month, balance, payouts_total, share_percent }; }; // --------------- Mock router ---------------------------------------------- async function mockFrApi(path, method, body) { await new Promise(r => setTimeout(r, 140 + Math.random() * 200)); // auth if (path === '/whoami') { if (!FR_SESSION.authed) { const e = new Error('unauthorized'); e.code = 401; throw e; } return { username: FR_SESSION.username, franchise_name: 'SpeedVPN', bot_username: '@speedvpn_bot' }; } if (path === '/login' && method === 'POST') { if (body && body.username === FR_PERMS.username && body.password === FR_PERMS.password) { FR_SESSION = { authed: true, username: body.username }; return { ok: true }; } const e = new Error('Неверный логин или пароль'); e.code = 401; throw e; } if (path === '/logout' && method === 'POST') { FR_SESSION = { authed: false, username: null }; return { ok: true }; } if (!FR_SESSION.authed) { const e = new Error('unauthorized'); e.code = 401; throw e; } // dashboard if (path === '/dashboard' && method === 'GET') return FR_DASH(); // users list const usersMatch = path.match(/^\/users(\?.*)?$/); if (usersMatch && method === 'GET') { const q = new URLSearchParams((usersMatch[1] || '').slice(1)); const query = (q.get('query') || '').toLowerCase(); const status = q.get('status') || ''; const page = parseInt(q.get('page') || '1', 10); let items = FR_USERS.filter(u => { if (query && !((u.tg_username || '').toLowerCase().includes(query) || (u.tg_name || '').toLowerCase().includes(query) || String(u.id).includes(query) || String(u.tg_id).includes(query))) return false; if (status && frUserStatus(u) !== status) return false; return true; }); const total = items.length; const PAGE = 25; items = items.slice((page - 1) * PAGE, page * PAGE); return { total, items, page, page_size: PAGE }; } // user detail const singleMatch = path.match(/^\/users\/(\d+)$/); if (singleMatch && method === 'GET') { const id = parseInt(singleMatch[1], 10); const u = FR_USERS.find(x => x.id === id); if (!u) { const e = new Error('not found'); e.code = 404; throw e; } // topups const topups = []; const n = 3 + (id % 5); for (let i = 0; i < n; i++) { topups.push({ amount: frPick([149, 249, 399, 699, 1290]), status: frRnd() < 0.9 ? 'paid' : (frRnd() < 0.5 ? 'pending' : 'failed'), method: frPick(['ЮKassa', 'CryptoBot', 'TON Space', 'Telegram Stars']), created_at: daysAgoISOfr(Math.floor(frRnd() * 90)), }); } return { ...u, topups: topups.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) }; } // message — always allowed const msgMatch = path.match(/^\/users\/(\d+)\/message$/); if (msgMatch && method === 'POST') return { ok: true }; // grant-days — gated const grantMatch = path.match(/^\/users\/(\d+)\/grant-days$/); if (grantMatch && method === 'POST') { if (!FR_PERMS.allow_grant_days) { const e = new Error('forbidden'); e.code = 403; throw e; } const id = parseInt(grantMatch[1], 10); const u = FR_USERS.find(x => x.id === id); if (!u) { const e = new Error('not found'); e.code = 404; throw e; } u.days_left += body.days; u.expire_at = daysAheadISOfr(u.days_left); return { ok: true }; } // promo — gated const promoMatch = path.match(/^\/users\/(\d+)\/give-promo$/); if (promoMatch && method === 'POST') { if (!FR_PERMS.allow_promo) { const e = new Error('forbidden'); e.code = 403; throw e; } return { ok: true, code: 'GIFT-' + Math.random().toString(36).slice(2, 8).toUpperCase() }; } // broadcast if (path === '/broadcast/jobs' && method === 'GET') return FR_BROADCAST_JOBS; if (path === '/broadcast' && method === 'POST') { const id = Math.max(...FR_BROADCAST_JOBS.map(j => j.id)) + 1; FR_BROADCAST_JOBS.unshift({ id, status: 'sending', sent: 0, failed: 0, created_at: new Date().toISOString() }); return { ok: true, id }; } // settings if (path === '/settings' && method === 'GET') return { ...FR_SETTINGS }; if (path === '/settings' && method === 'PUT') { // server-side clamp: never below min_price if (body.price != null && body.price < FR_SETTINGS.min_price) body.price = FR_SETTINGS.min_price; Object.assign(FR_SETTINGS, body); return { ok: true, settings: { ...FR_SETTINGS } }; } // withdrawals if (path === '/withdrawals' && method === 'GET') return FR_WITHDRAWALS; if (path === '/withdraw' && method === 'POST') { const dash = FR_DASH(); if (body.amount > dash.balance) { const e = new Error('Сумма превышает доступный баланс'); e.code = 400; throw e; } const id = Math.max(...FR_WITHDRAWALS.map(w => w.id)) + 1; FR_WITHDRAWALS.unshift({ id, amount: body.amount, status: 'pending', details: body.details, created_at: new Date().toISOString() }); return { ok: true, id }; } throw new Error('mock: not handled ' + method + ' ' + path); } window.FR_API = { frApi, MOCK, FR_PERMS, RUBfr, NUMfr, DATEfr, DATETIMEfr, frUserStatus };