// pages-system.jsx — Requisites + Content + Settings (NEW)
const { useState: useStateS, useMemo: useMemoS, useRef: useRefS, useEffect: useEffectS } = React;
/* ============================
REQUISITES — NEW
============================ */
const TYPE_META = {
card: { icon: "card", label: "Карта", color: "from-indigo-300 to-indigo-500" },
crypto: { icon: "bitcoin", label: "Крипто", color: "from-amber-400 to-amber-600" },
stars: { icon: "star", label: "TG Stars", color: "from-rose-400 to-rose-600" },
payment: { icon: "card", label: "PayPal/иное", color: "from-emerald-400 to-emerald-600" },
};
function RequisitesPage() {
const [list, setList] = useStateS([]);
const [loading, setLoading] = useStateS(true);
const [selected, setSelected] = useStateS(null);
const [filter, setFilter] = useStateS("all");
// Группа реквизитов: "" = общие (мои аккаунты без владельца), иначе ownerId
// владельца (напр. Рома). Каждый владелец может иметь свой набор карт; его
// аккаунты в боте принимают оплату на них (workspace резолвит по ownerId).
const [group, setGroup] = useStateS("");
const [groups, setGroups] = useStateS([]);
const [dirty, setDirty] = useStateS(false);
const loadGroups = React.useCallback(async () => {
setGroups(await fetchPaymentGroups());
}, []);
useEffectS(() => { loadGroups(); }, [loadGroups]);
const activeGroup = groups.find((g) => g.id === group) || groups[0] || { id: "", name: "Общие", own: true, count: 0 };
// Редактируем, если это общие ИЛИ владелец включил свои реквизиты. Иначе показ
// копии общих как превью (без записи), пока не нажал «Задать свои».
const editable = !group || !!activeGroup.own;
// ВАЖНО: НЕТ polling — это страница редактирования. Polling перезатирал бы ввод.
const reload = React.useCallback(async () => {
setLoading(true);
const data = await fetchPayments(group);
setList(data);
setLoading(false);
setDirty(false);
setSelected((cur) => cur || (data[0] ? data[0].id : null));
}, [group]);
useEffectS(() => {
let stopped = false;
setLoading(true);
(async () => {
const data = await fetchPayments(group);
if (stopped) return;
setList(data);
setLoading(false);
setDirty(false);
setSelected((cur) => cur || (data[0] ? data[0].id : null));
})();
return () => { stopped = true; };
}, [group]);
const onToggleOwn = async (enabled) => {
if (!group) return;
await toggleOwnerPayments(group, enabled);
await loadGroups();
await reload();
};
const filtered = useMemoS(() => {
if (filter === "all") return list;
return list.filter((r) => r.type === filter);
}, [list, filter]);
// Расфасовка списка по валютам: свой блок на гривну, свой на рубли и т.д.
// СБП попадает к рублям (это рублёвый способ), а монеты (USDT/BTC/TON) сведены
// в один блок «Крипта» — это способы оплаты, а не отдельные валюты прайса.
const CURRENCY_SECTIONS = [
{ id: "UAH", title: "Гривна", sign: "₴", match: (r) => r.currency === "UAH" },
{ id: "RUB", title: "Рубль", sign: "₽", match: (r) => r.currency === "RUB" },
{ id: "USD", title: "Доллар", sign: "$", match: (r) => r.currency === "USD" },
{ id: "CRYPTO", title: "Крипта", sign: "₿", match: (r) => r.type === "crypto" },
{ id: "STARS", title: "Telegram Stars", sign: "★", match: (r) => r.type === "stars" },
];
const grouped = useMemoS(() => {
const used = new Set();
const out = [];
CURRENCY_SECTIONS.forEach((s) => {
const rows = filtered.filter((r) => !used.has(r.id) && s.match(r));
rows.forEach((r) => used.add(r.id));
if (rows.length) out.push({ id: s.id, title: s.title, sign: s.sign, rows });
});
// Всё, что не подошло ни под один блок, показываем отдельно — чтобы реквизит
// не пропал из вкладки молча.
const rest = filtered.filter((r) => !used.has(r.id));
if (rest.length) out.push({ id: "OTHER", title: "Прочее", sign: "•", rows: rest });
return out;
}, [filtered]);
const current = list.find((r) => r.id === selected);
const totalActive = list.filter((r) => r.active).length;
const totalPaid = list.reduce((s, r) => s + r.paid7d, 0);
const topMethod = list.reduce(
(acc, r) => (r.sum7d > (acc?.sum7d || 0) ? r : acc),
null,
);
const toggleActive = (id) => {
if (!editable) return; // группа на общих реквизитах — только просмотр
setList(list.map((r) => (r.id === id ? { ...r, active: !r.active } : r)));
setDirty(true);
};
const updateCurrent = (patch) => {
if (!editable) return;
setList(list.map((r) => (r.id === selected ? { ...r, ...patch } : r)));
setDirty(true);
};
return (
{/* «Применить ко всем» убрано: реквизиты теперь глобальные (единый источник
defaults), правка применяется ко всем аккаунтам автоматически. */}
{/* Dirty-dot: визуальный маркер несохранённых изменений */}
{editable && (
{dirty && (
)}
{
await savePayments(group, list);
// После save — refetch чтобы paid7d/sum7d стали свежими
await reload();
}}
label={dirty ? "Сохранить все •" : "Сохранить все"}
/>
)}
{groups.length > 1 && (
{groups.map((g) => {
const on = g.id === group;
return (
);
})}
группы = владельцы из «Аккаунтов»
)}
{group && !activeGroup.own && (
Аккаунты «{activeGroup.name}» ({activeGroup.count}) принимают оплату на общие реквизиты. Ниже — их копия для примера, редактирование выключено.
)}
{group && !!activeGroup.own && (
«{activeGroup.name}» ({activeGroup.count} акк.) принимают на свои реквизиты. Правь карты ниже — общие не затронутся.
updateCurrent({ bank: e.target.value })} />
updateCurrent({ number: e.target.value })} />
updateCurrent({ label: e.target.value })} />
{/* Прогрев — только для банковских карт (грн/руб). Крипта/PayPal/
Stars не греются. Карта наращивает оборот аккуратно: не больше
N чеков в сутки + пауза после каждого, иначе бот её не выдаёт. */}
{current.type === "card" && (
Бот выдаёт эту карту не чаще {current.warmupDailyLimit ?? 3} раз в сутки, и после каждого присланного чека делает паузу {current.warmupPauseHours ?? 2} ч. Пока карта «остывает» — бот берёт другую карту той же валюты, а если других нет — уводит на другой способ.
>
)}
)}
{/* «Скопировать», «Удалить», нижняя «Сохранить» раньше были
без onClick = silent fail. «Сохранить» особенно сбивал с
толку (есть верхняя «Сохранить все» в SectionTitle которая
реально шлёт savePayments). Оставлена только реально
работающая Copy, остальные disabled с tooltip'ом. */}
Изменения сохраняются через «Сохранить все» в шапке страницы
)}
);
}
/* ============================
CONTENT — NEW
============================ */
function ChannelDeliveryEditor() {
// «Текст выдачи после оплаты» (private_channel_text). Таргетит базовые
// настройки (в этой панели нет per-account селектора). Отправляется клиенту
// КАК ЕСТЬ после оплаты канала (Phase 3).
const [text, setText] = useStateS("");
const [loaded, setLoaded] = useStateS(false);
const [saving, setSaving] = useStateS(false);
const [msg, setMsg] = useStateS("");
useEffectS(() => {
let stop = false;
(async () => {
const r = await fetchChannelText(null);
if (!stop) { setText((r && r.text) || ""); setLoaded(true); }
})();
return () => { stop = true; };
}, []);
const save = async () => {
setSaving(true); setMsg("");
try {
await saveChannelText(null, text);
setMsg("Сохранено");
} catch (e) {
setMsg("Ошибка: " + (e && e.message ? e.message : e));
} finally {
setSaving(false);
setTimeout(() => setMsg(""), 4000);
}
};
return (
Текст выдачи после оплаты
Отправляется клиенту как есть после оплаты канала: ссылка, несколько ссылок,
инструкция, пароль, правила — любой текст. Пусто — продажа разрешена, доступ
бот честно отложит.
);
}
function ContentPage() {
const [items, setItems] = useStateS([]);
const [loading, setLoading] = useStateS(true);
const [category, setCategory] = useStateS("all");
const [selected, setSelected] = useStateS(null);
const [search, setSearch] = useStateS("");
const [reindexing, setReindexing] = useStateS(false);
const [reindexMsg, setReindexMsg] = useStateS("");
// Polling 30 сек. При смене категории — перезагружаем с правильным фильтром.
useEffectS(() => {
let stopped = false;
const refresh = async () => {
const data = await fetchContent({ category, limit: 500 });
if (stopped) return;
setItems(data);
setLoading(false);
setSelected((cur) => cur || (data[0] ? data[0].id : null));
};
refresh();
const id = setInterval(refresh, 30_000);
return () => { stopped = true; clearInterval(id); };
}, [category]);
const filtered = useMemoS(() => {
if (!search) return items;
const q = search.toLowerCase();
return items.filter((m) =>
(m.summary || "").toLowerCase().includes(q) ||
(m.summarySales || "").toLowerCase().includes(q) ||
(m.tags || []).some((t) => t.toLowerCase().includes(q)) ||
m.name.toLowerCase().includes(q)
);
}, [search, items]);
const current = items.find((c) => c.id === selected);
// Подсчитываем количества по категориям для sidebar (только из текущей выборки).
const categoryCounts = useMemoS(() => {
const counts = { all: items.length, photo: 0, video: 0, circle: 0, channel: 0 };
for (const m of items) {
if (m.type === "photo") counts.photo += 1;
else if (m.type === "video") counts.video += 1;
if (m.category === "circle" || m.category === "custom_circle") counts.circle += 1;
if (m.category === "channel") counts.channel += 1;
}
return counts;
}, [items]);
// Индексированных / без AI — для блока внизу sidebar
const aiStats = useMemoS(() => {
let indexed = 0, missing = 0, errored = 0;
for (const m of items) {
if (m.aiStatus === "indexed") indexed += 1;
else if (m.aiStatus === "error") errored += 1;
else missing += 1;
}
return { indexed, missing, errored };
}, [items]);
const handleReindex = async () => {
if (reindexing) return;
if (!window.confirm("Запустить AI-переиндексацию ВСЕГО контента? Опишет все фото и видео заново под текущую модель (несколько минут, в фоне).")) return;
setReindexing(true);
setReindexMsg("запуск…");
try {
const r = await reindexContent(true);
if (r && r.alreadyRunning) setReindexMsg("уже идёт…");
const poll = async () => {
const st = await reindexStatus();
if (st.phase === "error") {
setReindexMsg("ошибка: " + (st.error || ""));
setReindexing(false);
} else if (st.running || st.phase === "running") {
setReindexMsg(`описано ${st.done || 0}/${st.total || "?"}${st.errors ? ` (ошибок ${st.errors})` : ""}`);
setTimeout(poll, 2000);
} else {
setReindexMsg(st.phase === "done"
? `готово: ${st.indexed || 0} описано${st.errors ? `, ${st.errors} ошибок` : ""}`
: "готово");
setReindexing(false);
try { setItems(await fetchContent({ category, limit: 500 })); } catch (e) {}
setTimeout(() => setReindexMsg(""), 8000);
}
};
setTimeout(poll, 2500);
} catch (e) {
setReindexMsg("ошибка: " + (e?.message || e));
setReindexing(false);
}
};
return (
{/* «Применить ко всем» убрано: контент глобальный (общий мастер
admin_panel/content), доступен всем аккаунтам автоматически. */}
{reindexMsg && {reindexMsg}}
);
}
function SettingsSection({ title, subtitle, children }) {
return (
{title}
{subtitle &&
{subtitle}
}
{children}
);
}
function SettingRow({ label, hint, children }) {
return (
{label}
{hint &&
{hint}
}
{children}
);
}
function EnvField({ label, value, secret, mono }) {
// value уже маскирована на бэке (xxxx***yyyy) — отображаем как есть.
// Copy-to-clipboard работает для всех полей.
const safeValue = value || "";
const onCopy = () => { try { navigator.clipboard.writeText(safeValue); } catch (_) {} };
return (
{label}
{secret && (
)}
);
}
function ThemeCard({ label, active }) {
return (
);
}
function DangerRow({ label, hint, critical }) {
return (
{label}
{hint &&
{hint}
}
);
}
/* ===========================================================
ProcessesSection — управление автозапуском и self-restart uvicorn.
Размещена в Settings → Процессы (первый пункт sidebar'а).
=========================================================== */
function ProcessesSection() {
const [autostart, setAutostart] = useStateS(null); // null = loading
const [savingAutostart, setSavingAutostart] = useStateS(false);
const [nudgeDedup, setNudgeDedup] = useStateS(null);
const [savingNudgeDedup, setSavingNudgeDedup] = useStateS(false);
const [restartConfirm, setRestartConfirm] = useStateS(false);
const [restarting, setRestarting] = useStateS(false);
const [restartElapsedSec, setRestartElapsedSec] = useStateS(0);
const [lastRestart, setLastRestart] = useStateS(null);
// Загрузка текущих admin-настроек
useEffectS(() => {
let stopped = false;
fetchAdminSettings().then((s) => {
if (stopped) return;
setAutostart(!!s?.autoStartBot);
setNudgeDedup(s?.nudgeDedupEnabled !== false); // default true
setLastRestart(s?.lastRestart || null);
});
return () => { stopped = true; };
}, []);
const onToggleAutostart = async () => {
if (savingAutostart || autostart === null) return;
const next = !autostart;
setSavingAutostart(true);
setAutostart(next); // optimistic
try {
const res = await updateAdminSettings({ autoStartBot: next });
setAutostart(!!res?.autoStartBot);
if (window.__toast) {
window.__toast.push({
kind: "ok",
text: next
? "Автозапуск бота включён — будет стартовать с веб-сервером"
: "Автозапуск бота выключен",
});
}
} catch (e) {
// rollback
setAutostart(!next);
if (window.__toast) {
window.__toast.push({
kind: "err",
text: "Не удалось сохранить: " + String(e.message || e).slice(0, 100),
});
}
} finally {
setSavingAutostart(false);
}
};
const onToggleNudgeDedup = async () => {
if (savingNudgeDedup || nudgeDedup === null) return;
const next = !nudgeDedup;
setSavingNudgeDedup(true);
setNudgeDedup(next);
try {
const res = await updateAdminSettings({ nudgeDedupEnabled: next });
setNudgeDedup(res?.nudgeDedupEnabled !== false);
if (window.__toast) {
window.__toast.push({
kind: "ok",
text: next
? "Anti-repetition включён — guard блокирует повторы nudge-фраз"
: "Anti-repetition выключен — guard пропускает все nudges как раньше",
});
}
} catch (e) {
setNudgeDedup(!next);
if (window.__toast) {
window.__toast.push({
kind: "err",
text: "Не удалось сохранить: " + String(e.message || e).slice(0, 100),
});
}
} finally {
setSavingNudgeDedup(false);
}
};
const performRestart = async () => {
setRestartConfirm(false);
setRestarting(true);
setRestartElapsedSec(0);
// Локальный таймер прогресса
const tickStart = Date.now();
const tickInterval = setInterval(() => {
setRestartElapsedSec(Math.floor((Date.now() - tickStart) / 1000));
}, 250);
try {
const res = await adminRestart();
// Сервер примерно через 300мс начнёт os._exit. После этого fetch'и
// упадут с network error пока новый процесс не поднялся.
console.log("admin restart response", res);
} catch (e) {
// 409 = двойной клик — показать info, не прерывать пользовательский flow
const msg = String(e.message || e);
if (msg.includes("409")) {
clearInterval(tickInterval);
setRestarting(false);
if (window.__toast) {
window.__toast.push({ kind: "info", text: "Перезапуск уже идёт" });
}
return;
}
// Другая ошибка — но сервер мог всё равно стартануть exit. Продолжаем
// polling здоровья — если поднимется, всё ок.
console.warn("admin restart returned error, continuing health polling", e);
}
// Polling /api/health пока новый сервер не поднимется
const POLL_INTERVAL_MS = 500;
const POLL_TIMEOUT_MS = 30000; // 30с — с запасом на retry-bind окно (5с)
const startedAt = Date.now();
const oldServerStarted = lastHealthStartedAt;
let detected = false;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
try {
const h = await fetchHealth();
// Если startedAt > oldServerStarted — это уже новый процесс.
if (h && h.startedAt && (!oldServerStarted || h.startedAt > oldServerStarted)) {
detected = true;
break;
}
} catch {
// network error — ожидаемо во время рестарта.
}
}
clearInterval(tickInterval);
setRestarting(false);
if (detected) {
if (window.__toast) {
window.__toast.push({ kind: "ok", text: "Веб-сервер успешно перезапущен" });
}
// Перезагружаем страницу — забираем свежий JSX (cache-bust по startup_ts).
setTimeout(() => {
try { window.location.reload(); } catch {}
}, 800);
} else {
if (window.__toast) {
window.__toast.push({
kind: "err",
text: "Сервер не ответил за 30 сек. Смотри panel_data/logs/restart.log",
});
}
}
};
return (
{/* Тумблер автозапуска */}
Автозапуск бота
Когда включено — бот стартует автоматически каждый раз когда поднимается веб-сервер.
Это нужно чтобы после «Перезапустить веб-сервер» бот тоже поднимался без ручного клика.
{/* Anti-repetition guard для nudge (Batch 2) */}
Anti-repetition для nudge
Запрещает боту слать одну и ту же фразу подряд при автоматических пингах
(«ну что, ещё думаешь?» × 50 раз). Первый дубль — один re-gen LLM-вызов,
повторный — skip без вызова. Persistent циклы (24ч/72ч) не блокируются.
Если конверсия упадёт — выключи переключателем, рестарт не нужен.
{/* Перезапуск веб-сервера */}
Перезапустить веб-сервер
Полный рестарт uvicorn — нужен после обновления Python-кода.
Текущая сессия прервётся на ~5 секунд. Бот переживёт рестарт независимо.
Если автозапуск выше включён — бот тоже поднимется с новым кодом.
{/* lastRestart info */}
{lastRestart && lastRestart.at && (
последний рестарт: {String(lastRestart.at).slice(0, 19).replace("T", " ")}
{lastRestart.newPid ? ` → новый pid=${lastRestart.newPid}` : ""}
)}
{/* Confirmation dialog */}
{restartConfirm && (
Перезапустить веб-сервер?
Текущая сессия прервётся на ~5 секунд. Бот продолжит работу.
Если автозапуск включён, бот поднимется автоматически с новым кодом.
)}
{/* Full-screen overlay во время рестарта */}
{restarting && (
Перезагружается веб-сервер…
прошло {restartElapsedSec}с
Страница автоматически обновится, как только новый процесс ответит.
Если не поднимется за 30с — смотри panel_data/logs/restart.log.
)}
);
}
// Cache последнего startedAt из /api/health — используется для детекции
// что после рестарта поднялся НОВЫЙ процесс (а не тот же самый что не успел упасть).
let lastHealthStartedAt = null;
// Auto-toast: при первой загрузке UI проверяем health.autostartBot.
// Если бот был auto-started на этом подъёме сервера — один раз показываем
// toast. localStorage хранит startedAt, чтобы при F5 в той же сессии toast
// не повторялся.
async function _checkAutostartToast() {
try {
const h = await fetchHealth();
if (!h) return;
lastHealthStartedAt = h.startedAt;
const a = h.autostartBot;
if (!a) return;
const lastShownKey = "zxc.autostart.shownFor";
const lastShown = window.localStorage.getItem(lastShownKey);
const currentKey = String(h.startedAt);
if (lastShown === currentKey) return; // уже показывали для этого подъёма
if (a.started === true && a.wasRunning === false) {
if (window.__toast) {
window.__toast.push({
kind: "ok",
text: "Бот автоматически запущен",
});
}
} else if (a.error) {
if (window.__toast) {
window.__toast.push({
kind: "err",
text: "Автозапуск бота не удался: " + String(a.error).slice(0, 120),
});
}
}
try { window.localStorage.setItem(lastShownKey, currentKey); } catch {}
} catch (e) {
// health недоступен — это могло быть во время рестарта. Молча.
}
}
// Запускаем проверку один раз при загрузке скрипта.
if (typeof window !== "undefined") {
// Небольшая задержка — даём React смонтировать toast-контейнер.
setTimeout(_checkAutostartToast, 800);
}
Object.assign(window, { RequisitesPage, ContentPage, SettingsPage, ProcessesSection });