// ======================================================================= // MariusVPN Franchise — Login + screens (Dashboard, Users, Broadcast, // Settings, Payouts). Uses primitives from marius-admin-ui.jsx and // API helpers from marius-fr-data.jsx. // ======================================================================= const { frApi, RUBfr, NUMfr, DATEfr, DATETIMEfr, frUserStatus } = window.FR_API; // ======================================================================= // LOGIN // ======================================================================= function FrLogin({ onLogin }) { const [username, setU] = React.useState(''); const [password, setP] = React.useState(''); const [loading, setLoading] = React.useState(false); const [err, setErr] = React.useState(''); const t = useToast(); const submit = async (e) => { e.preventDefault(); setErr(''); setLoading(true); try { await frApi('/login', { method: 'POST', body: { username, password } }); const me = await frApi('/whoami'); onLogin(me); } catch (e) { setErr(e.message || 'Не удалось войти'); } finally { setLoading(false); } }; return (
Панель управления
Вход для франчайзи

Вход для франчайзи

Логин и пароль выдаёт владелец сети.

setU(e.target.value)} placeholder="speedvpn" autoFocus icon={ICONS.users}/> setP(e.target.value)} placeholder="••••••••" icon={ICONS.shield}/> {err && (
{err}
)}
Демо-доступ: speedvpn / demo
© 2026 · Панель управления франшизой
); } // ======================================================================= // DASHBOARD // ======================================================================= function FrDashboard({ goto, openWithdraw }) { const [data, setData] = React.useState(null); React.useEffect(() => { frApi('/dashboard').then(setData); }, []); if (!data) return ; return (
} variant="primary" onClick={openWithdraw}>Запросить выплату} />
Как считается выручка
С каждой оплаты вашего пользователя
50 / 50
Оплата клиента
249 ₽
Вам
{Math.round(249 * data.share_percent / 100)} ₽
Выручка зачисляется на баланс сразу после оплаты. Запросить вывод можно в любой момент во вкладке «Выплаты».
Быстрые действия
Самые частые операции
); } // ======================================================================= // USERS // ======================================================================= function FrUsers() { const [query, setQuery] = React.useState(''); const [status, setStatus] = React.useState(''); const [page, setPage] = React.useState(1); const [data, setData] = React.useState(null); const [openId, setOpenId] = React.useState(null); const t = useToast(); const load = React.useCallback(async () => { const q = new URLSearchParams(); if (query) q.set('query', query); if (status) q.set('status', status); q.set('page', String(page)); const res = await frApi('/users?' + q.toString()); setData(res); }, [query, status, page]); React.useEffect(() => { load(); }, [load]); React.useEffect(() => { setPage(1); }, [query, status]); const totalPages = data ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1; return (
setQuery(e.target.value)} />
{!data ? : data.items.length === 0 ? : (
{data.items.map(u => { const st = frUserStatus(u); return ( setOpenId(u.id)}> ); })}
Пользователь TG-ID Баланс Подписка Пополнений Статус
{u.tg_name}
@{u.tg_username}
{u.tg_id} {RUBfr(u.balance)} {u.days_left > 0 ? {u.days_left} дн. : истекла} {DATEfr(u.expire_at)} {u.topups_count} {st === 'active' && активна} {st === 'expired' && истекла} {st === 'blocked' && заблокирован}
)} {data && totalPages > 1 && (
Страница {page} из {totalPages}
)}
{openId && setOpenId(null)} onChanged={load}/>}
); } function FrUserDetail({ userId, onClose, onChanged }) { const [u, setU] = React.useState(null); const [msgOpen, setMsgOpen] = React.useState(false); const [grantOpen, setGrantOpen] = React.useState(false); const [promoOpen, setPromoOpen] = React.useState(false); React.useEffect(() => { frApi(`/users/${userId}`).then(setU); }, [userId]); const refresh = async () => { const r = await frApi(`/users/${userId}`); setU(r); onChanged && onChanged(); }; return ( <> {!u ? : (
{u.tg_name.slice(0, 1)}
{u.tg_name}
@{u.tg_username} · ID {u.id}
{frUserStatus(u) === 'active' && активна} {frUserStatus(u) === 'expired' && истекла} {frUserStatus(u) === 'blocked' && заблокирован}
Баланс
{RUBfr(u.balance)}
Подписка
{u.days_left > 0 ? `${u.days_left} дн.` : истекла}
{DATEfr(u.expire_at)}
Оплат
{u.topups_count}
Действия
setMsgOpen(true)} /> setGrantOpen(true)} /> setPromoOpen(true)} />
История оплат
{u.topups.map((t, i) => ( ))}
Дата Метод Сумма Статус
{DATETIMEfr(t.created_at)} {t.method} {RUBfr(t.amount)} {t.status === 'paid' && оплачено} {t.status === 'pending' && в ожидании} {t.status === 'failed' && ошибка}
)}
{msgOpen && setMsgOpen(false)}/>} {grantOpen && setGrantOpen(false)} onDone={refresh}/>} {promoOpen && setPromoOpen(false)}/>} ); } function FrActionRow({ icon, label, hint, allowed = true, always = false, onClick }) { const enabled = always || allowed; return ( ); } function FrMessageDialog({ userId, onClose }) { const [text, setText] = React.useState(''); const [loading, setLoading] = React.useState(false); const t = useToast(); const send = async () => { setLoading(true); try { await frApi(`/users/${userId}/message`, { method: 'POST', body: { text } }); t.ok('Сообщение отправлено'); onClose(); } catch (e) { t.err(e.message || 'Не удалось отправить'); } finally { setLoading(false); } }; return ( } >