// =======================================================================
// 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 (
Панель управления
Вход для франчайзи
Вход для франчайзи
Логин и пароль выдаёт владелец сети.
Демо-доступ: 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
Вам
{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 (
{!data ? : data.items.length === 0 ? : (
| Пользователь |
TG-ID |
Баланс |
Подписка |
Пополнений |
Статус |
|
{data.items.map(u => {
const st = frUserStatus(u);
return (
setOpenId(u.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)}
Действия
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 (
>}
>
);
}
function FrGrantDialog({ userId, onClose, onDone }) {
const [days, setDays] = React.useState(7);
const [loading, setLoading] = React.useState(false);
const t = useToast();
const send = async () => {
setLoading(true);
try {
await frApi(`/users/${userId}/grant-days`, { method: 'POST', body: { days: Number(days) } });
t.ok(`Начислено ${days} дней`);
onDone(); onClose();
} catch (e) {
if (e.code === 403) t.err('Действие недоступно — запросите доступ у владельца');
else t.err(e.message || 'Ошибка');
} finally { setLoading(false); }
};
return (
>}
>
setDays(e.target.value)}/>
{[3, 7, 14, 30].map(n => (
))}
);
}
function FrPromoDialog({ userId, onClose }) {
const [days, setDays] = React.useState(7);
const [loading, setLoading] = React.useState(false);
const [code, setCode] = React.useState('');
const t = useToast();
const send = async () => {
setLoading(true);
try {
const r = await frApi(`/users/${userId}/give-promo`, { method: 'POST', body: { new: { type: 'days', value: Number(days) } } });
setCode(r.code || 'GIFT-XXXXXX');
t.ok('Промокод создан');
} catch (e) {
if (e.code === 403) t.err('Действие недоступно — запросите доступ у владельца');
else t.err(e.message || 'Ошибка');
} finally { setLoading(false); }
};
const copy = () => { navigator.clipboard.writeText(code); t.ok('Скопировано'); };
return (
{!code && }
>}
>
{!code ? (
<>
setDays(e.target.value)}/>
Промокод одноразовый — после активации станет недействительным.
>
) : (
{code}
)}
);
}
// =======================================================================
// BROADCAST
// =======================================================================
function FrBroadcast() {
const [text, setText] = React.useState('');
const [premiumEmoji, setPremiumEmoji] = React.useState(false);
const [mediaType, setMediaType] = React.useState('none');
const [mediaName, setMediaName] = React.useState('');
const [buttons, setButtons] = React.useState([]);
const [jobs, setJobs] = React.useState([]);
const [sending, setSending] = React.useState(false);
const t = useToast();
const loadJobs = React.useCallback(() => frApi('/broadcast/jobs').then(setJobs), []);
React.useEffect(() => { loadJobs(); }, [loadJobs]);
const send = async () => {
setSending(true);
try {
await frApi('/broadcast', {
method: 'POST',
body: {
text,
media: mediaType === 'none' ? null : { type: mediaType, file_id: 'mock_' + Date.now() },
buttons: buttons.filter(b => b.text && b.url),
premium_emoji: premiumEmoji,
},
});
t.ok('Рассылка поставлена в очередь');
setText(''); setMediaType('none'); setMediaName(''); setButtons([]);
loadJobs();
} catch (e) { t.err(e.message || 'Ошибка'); }
finally { setSending(false); }
};
return (
Конструктор
Медиа
{[
{ v: 'none', l: 'Без медиа', i: ICONS.close },
{ v: 'photo', l: 'Фото', i: ICONS.picture },
{ v: 'video', l: 'Видео', i: ICONS.zap },
].map(o => (
))}
{mediaType !== 'none' && (
)}
Инлайн-кнопки
{buttons.length === 0 ? (
Можно добавить до 5 кнопок-ссылок.
) : (
)}
Премиум-эмодзи
Бот должен иметь Telegram Premium
Предпросмотр
{mediaType === 'photo' && (
)}
{mediaType === 'video' && (
)}
{text || Текст сообщения появится здесь…}
{buttons.filter(b => b.text).length > 0 && (
{buttons.filter(b => b.text).map((b, i) => (
{b.text}
))}
)}
{jobs.length === 0
?
Ещё не было рассылок.
: jobs.map(j => (
#{j.id}
{j.status === 'sent' && Отправлено}
{j.status === 'sending' && Идёт}
{j.status === 'scheduled' && Запланировано}
{j.status === 'failed' && Ошибка}
{DATETIMEfr(j.created_at)}
{NUMfr(j.sent)}
{j.failed > 0 &&
−{j.failed}
}
))}
);
}
// =======================================================================
// SETTINGS
// =======================================================================
function FrSettings() {
const [s, setS] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const t = useToast();
React.useEffect(() => { frApi('/settings').then(setS); }, []);
const upd = (patch) => setS(x => ({ ...x, ...patch }));
const updBanner = (id, patch) => setS(x => ({ ...x, banners: x.banners.map(b => b.id === id ? { ...b, ...patch } : b) }));
const addBanner = () => setS(x => ({ ...x, banners: [...x.banners, { id: Date.now(), title: '', body: '', active: true }] }));
const delBanner = (id) => setS(x => ({ ...x, banners: x.banners.filter(b => b.id !== id) }));
const save = async () => {
setSaving(true);
try {
const res = await frApi('/settings', { method: 'PUT', body: s });
if (res.settings) setS(res.settings);
t.ok('Настройки сохранены');
} catch (e) { t.err(e.message || 'Ошибка'); }
finally { setSaving(false); }
};
if (!s) return ;
const priceTooLow = s.price < s.min_price;
return (
}>Сохранить}
/>
Цена и оформление
Канал и отзывы
upd({ required_channel_username: e.target.value })}/>
upd({ review_url: e.target.value })} icon={ICONS.link}/>
upd({ stories_bonus_enabled: v })}/>
Бонус за сторис
+3 дня за отметку бота в Stories
Баннеры
Появляются в боте над прайс-листом
);
}
// =======================================================================
// PAYOUTS / WITHDRAWALS
// =======================================================================
function FrWithdrawForm({ balance, onDone }) {
const [amount, setAmount] = React.useState('');
const [details, setDetails] = React.useState('');
const [method, setMethod] = React.useState('card');
const [loading, setLoading] = React.useState(false);
const t = useToast();
const amt = Number(amount || 0);
const tooMuch = amt > balance;
const tooLow = amt > 0 && amt < 500;
const submit = async () => {
setLoading(true);
try {
await frApi('/withdraw', { method: 'POST', body: { amount: amt, details: `${method}: ${details}` } });
t.ok('Запрос на выплату отправлен');
setAmount(''); setDetails('');
onDone && onDone();
} catch (e) { t.err(e.message || 'Ошибка'); }
finally { setLoading(false); }
};
return (
Запросить выплату
Доступно к выводу: {RUBfr(balance)}
setAmount(e.target.value)}/>
{[balance, Math.floor(balance / 2), 5000].filter(v => v > 0).map((v, i) => (
))}
Метод
{[
{ v: 'card', l: 'Карта' },
{ v: 'ton', l: 'TON' },
{ v: 'crypto', l: 'USDT' },
].map(o => (
))}
setDetails(e.target.value)} placeholder={method === 'card' ? '0000 0000 0000 0000' : 'UQDx…'}/>
);
}
function FrPayouts({ initialOpen }) {
const [dash, setDash] = React.useState(null);
const [list, setList] = React.useState(null);
const load = React.useCallback(async () => {
const [d, l] = await Promise.all([frApi('/dashboard'), frApi('/withdrawals')]);
setDash(d); setList(l);
}, []);
React.useEffect(() => { load(); }, [load]);
if (!dash || !list) return ;
return (
История выплат
{NUMfr(list.length)}
{list.length === 0 ? (
) : (
| Дата |
Реквизиты |
Сумма |
Статус |
{list.map(w => (
| {DATETIMEfr(w.created_at)} |
{w.details} |
{RUBfr(w.amount)} |
{w.status === 'paid' && выплачено}
{w.status === 'pending' && в обработке}
{w.status === 'rejected' && отклонено}
|
))}
)}
);
}
// --------------- Exports -------------------------------------------------
Object.assign(window, {
FrLogin, FrDashboard, FrUsers, FrBroadcast, FrSettings, FrPayouts,
});