// admin.jsx — Admin dashboard, orders/users tables, AI logs, A/B tests, support.
// Wired to /api/admin/* JSON endpoints. UI на русском, design сохранён без изменений.
// ============ helpers ============
const adminFetch = async (path, opts = {}) => {
const res = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
...opts,
});
if (!res.ok) {
let msg = `HTTP ${res.status}`;
try { const j = await res.json(); if (j && j.error) msg = j.error; } catch (_) {}
throw new Error(msg);
}
return res.json();
};
const fmtRub = (v) => {
if (v == null) return '0 ₽';
return Math.round(v).toLocaleString('ru') + ' ₽';
};
const fmtNum = (v) => (v == null ? '0' : v.toLocaleString('ru'));
const fmtPct = (v, d = 1) => (v == null ? '0%' : `${Number(v).toFixed(d)}%`);
const fmtDateTime = (iso) => {
if (!iso) return '—';
try {
const d = new Date(iso);
return d.toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
} catch (_) { return iso; }
};
const fmtRelTime = (iso) => {
if (!iso) return '—';
const d = new Date(iso);
const s = Math.floor((Date.now() - d.getTime()) / 1000);
if (s < 60) return `${s} сек назад`;
if (s < 3600) return `${Math.floor(s/60)} мин назад`;
if (s < 86400) return `${Math.floor(s/3600)} ч назад`;
return `${Math.floor(s/86400)} дн назад`;
};
const fmtDate = (iso) => {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit', year: 'numeric' });
};
const PRIO_LABELS = { high: 'высокий', med: 'средний', low: 'низкий' };
const PRIO_COLORS = { high: 'rose', med: 'gold', low: 'celest' };
const ORDER_STATUS_COLORS = {
pending_payment: 'celest',
paid: 'gold',
generating: 'gold',
ready: 'sage',
failed: 'rose',
cancelled: 'rose',
refunded: 'rose',
};
// ============ shared UI ============
const AdminHeader = ({ subPage, onSubPage, currentUser }) => {
const items = [
{ id: 'dashboard', label: 'Дашборд' },
{ id: 'orders', label: 'Заказы' },
{ id: 'products', label: 'Продукты' },
{ id: 'users', label: 'Пользователи' },
{ id: 'ai', label: 'AI / Логи' },
{ id: 'abtests', label: 'A/B тесты' },
{ id: 'support', label: 'Поддержка' },
{ id: 'settings', label: 'Настройки' },
];
const email = currentUser?.email || 'admin@star-path.ru';
const initial = (email[0] || 'A').toUpperCase();
return (
Star Path
{items.map(it => (
))}
{email}
{initial}
);
};
const LoadingState = ({ text = 'Загрузка…' }) => (
{text}
);
const ErrorState = ({ message, onRetry }) => (
Ошибка загрузки
{message}
{onRetry &&
}
);
const EmptyState = ({ text }) => (
{text || 'Пока нет данных'}
);
// ——— Stat tile ———
const StatTile = ({ label, value, unit, delta, sub, sparkline = [] }) => {
const sparkW = 120, sparkH = 36;
const arr = (sparkline || []).filter(v => v != null);
const max = arr.length ? Math.max(...arr, 1) : 1;
const min = arr.length ? Math.min(...arr, 0) : 0;
const range = max - min || 1;
const points = arr.map((v, i) => {
const x = arr.length > 1 ? (i / (arr.length - 1)) * sparkW : sparkW / 2;
const y = sparkH - ((v - min) / range) * sparkH;
return `${x},${y}`;
}).join(' ');
const positive = (delta || 0) >= 0;
return (
{label}
{delta !== undefined && delta !== null && (
{positive ? '↑' : '↓'} {Math.abs(Math.round(delta))}%
)}
{value}
{unit && {unit}}
{sub}
{arr.length > 0 && (
)}
);
};
// ============ DASHBOARD ============
const RevenueChart = ({ daily }) => {
const data = (daily || []).map(d => d.rub);
if (!data.length) {
return (
);
}
const max = Math.max(...data, 1);
const totalRub = data.reduce((a, b) => a + b, 0);
return (
Выручка за 30 дней
{fmtRub(totalRub)}
{daily.length} дн · {data.reduce((s,_,i) => s + (daily[i]?.orders || 0), 0)} заказов
{[0, 25, 50, 75, 100].map((y) => (
{fmtRub(Math.round(max * y / 100))}
))}
{data.map((v, i) => (
))}
);
};
const ProductMix = ({ mix }) => {
if (!mix || !mix.length) {
return (
);
}
const palette = ['var(--gold)', 'var(--celest)', 'var(--rose)', 'var(--sage)'];
const data = mix.map((p, i) => ({
name: p.service_ru || p.service,
val: Math.round((p.share || 0) * 100),
color: palette[i % palette.length],
}));
const r = 70, c = 2 * Math.PI * r;
let acc = 0;
return (
Карта продуктов
{data.map((d, i) => (
))}
);
};
const Funnel = ({ funnel }) => {
if (!funnel || funnel.visits === 0) {
return (
);
}
const visits = funnel.unique_visitors || funnel.visits || 1;
const pct = (n) => Math.min(100, Math.max(0, (n / visits) * 100));
const data = [
{ label: 'Уникальных визитов', val: funnel.unique_visitors || 0, pct: 100 },
{ label: 'Просмотры заказа', val: funnel.order_views || 0, pct: pct(funnel.order_views || 0) },
{ label: 'Открыли оплату', val: funnel.checkouts || 0, pct: pct(funnel.checkouts || 0) },
{ label: 'Оплатили', val: funnel.paid || 0, pct: pct(funnel.paid || 0) },
];
return (
Воронка
30 дней · {fmtPct(funnel.conv_vis_to_paid, 2)} конверсия
{data.map((d, i) => (
{d.label}
{fmtNum(d.val)}
{d.pct.toFixed(1)}%
))}
);
};
const AdminDashboard = () => {
const [state, setState] = React.useState({ loading: true, data: null, error: null });
const load = React.useCallback(async () => {
setState(s => ({ ...s, loading: true, error: null }));
try {
const data = await adminFetch('/api/admin/dashboard');
setState({ loading: false, data, error: null });
} catch (e) {
setState({ loading: false, data: null, error: e.message });
}
}, []);
React.useEffect(() => { load(); }, [load]);
const nowStr = new Date().toLocaleString('ru', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' });
if (state.loading && !state.data) {
return (
{nowStr} MSK
Командный дашборд
);
}
if (state.error) {
return (
);
}
const k = state.data.kpis || {};
const rev = k.revenue_30d || {};
const ord = k.orders_30d || {};
const ltv = k.ltv_avg || {};
const ref = k.refund_rate || {};
const errors = state.data.errors || {};
const funnel = state.data.funnel;
const support = state.data.support;
const recent = state.data.recent_orders || [];
const active = state.data.active_generations || [];
return (
{nowStr} MSK
Командный дашборд
{/* Top stats */}
{/* Revenue + funnel */}
{/* Product mix + recent orders */}
Последние заказы
NPS {support?.nps ? Math.round(support.nps) : '—'} · CSAT {support?.csat_avg ? support.csat_avg.toFixed(1) : '—'}
{recent.length === 0 &&
}
{recent.length > 0 && (
{recent.slice(0, 6).map(o => (
{o.id.slice(0, 8)}
{o.user_name || o.user_email}
{o.service_ru}
{fmtRub(o.amount_rub)}
{o.status_ru}
))}
)}
{/* Live AI status */}
AI-генерация · сейчас
{active.length} активны
{active.length === 0 &&
}
{active.length > 0 && (
{active.map((j) => (
{j.id.slice(0, 8)}
{fmtRelTime(j.created_at)}
{j.service_ru}
{j.user_name || j.user_email}
{j.status === 'generating' ? 'генерация' : 'в очереди'}
))}
)}
);
};
// ============ ORDERS ============
const AdminOrders = () => {
const [state, setState] = React.useState({ loading: true, orders: [], total: 0, error: null });
const [q, setQ] = React.useState('');
const [status, setStatus] = React.useState('');
const [service, setService] = React.useState('');
const [page, setPage] = React.useState(1);
const [actionId, setActionId] = React.useState(null);
const pageSize = 50;
const load = React.useCallback(async () => {
setState(s => ({ ...s, loading: true, error: null }));
try {
const params = new URLSearchParams({
limit: String(pageSize),
offset: String((page - 1) * pageSize),
});
if (q) params.set('q', q);
if (status) params.set('status', status);
if (service) params.set('service', service);
const d = await adminFetch('/api/admin/orders?' + params.toString());
setState({ loading: false, orders: d.orders || [], total: d.total || 0, error: null });
} catch (e) {
setState({ loading: false, orders: [], total: 0, error: e.message });
}
}, [q, status, service, page]);
// debounce search
React.useEffect(() => {
const t = setTimeout(load, 250);
return () => clearTimeout(t);
}, [load]);
const action = async (id, endpoint, body) => {
setActionId(id + endpoint);
try {
await adminFetch(`/api/admin/orders/${id}/${endpoint}`, {
method: 'POST',
body: body ? JSON.stringify(body) : null,
});
await load();
} catch (e) {
alert('Ошибка: ' + e.message);
} finally {
setActionId(null);
}
};
const onResend = (o) => action(o.id, 'resend');
const onRequeue = (o) => action(o.id, 'requeue');
const onRerender = (o) => action(o.id, 'rerender-pdf');
const onRefund = (o) => {
const reason = prompt('Причина возврата:');
if (reason && reason.trim()) action(o.id, 'refund', { reason: reason.trim() });
};
const totalPages = Math.max(1, Math.ceil(state.total / pageSize));
return (
{fmtNum(state.total)} заказов всего
Заказы
{state.error &&
}
{!state.error && state.loading && state.orders.length === 0 &&
}
{!state.error && !state.loading && state.orders.length === 0 &&
}
{state.orders.length > 0 && (
{['ID', 'Клиент', 'По кому', 'Продукт', 'Дата', 'Сумма', 'Статус', 'Действия'].map((h, i) => (
{h}
))}
{state.orders.map((r, i) => (
{r.id.slice(0, 8)}
{r.user_name || '—'}
{r.user_email}
{r.person_name || —}
{r.service_ru}
{fmtDateTime(r.created_at)}
{fmtRub(r.amount_rub)}
{r.status_ru}
{r.status === 'ready' && (
)}
{(r.status === 'failed' || r.status === 'ready' || r.status === 'generating') && (
)}
{r.status === 'ready' && (
)}
{['paid', 'generating', 'ready', 'failed'].includes(r.status) && (
)}
))}
)}
{state.total > 0 && (
{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, state.total)} из {fmtNum(state.total)}
{page} / {totalPages}
)}
);
};
// ============ USERS ============
const AdminUsers = () => {
const [state, setState] = React.useState({ loading: true, users: [], total: 0, stats: {}, error: null });
const [q, setQ] = React.useState('');
const [page, setPage] = React.useState(1);
const [promoting, setPromoting] = React.useState(null);
const pageSize = 50;
const load = React.useCallback(async () => {
setState(s => ({ ...s, loading: true, error: null }));
try {
const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) });
if (q) params.set('q', q);
const d = await adminFetch('/api/admin/users?' + params.toString());
setState({ loading: false, users: d.users || [], total: d.total || 0, stats: d.stats || {}, error: null });
} catch (e) {
setState({ loading: false, users: [], total: 0, stats: {}, error: e.message });
}
}, [q, page]);
React.useEffect(() => {
const t = setTimeout(load, 250);
return () => clearTimeout(t);
}, [load]);
const onToggleRole = async (u) => {
const newRole = u.role === 'admin' ? 'user' : 'admin';
if (!confirm(`Сменить роль ${u.email} на «${newRole}»?`)) return;
setPromoting(u.id);
try {
await adminFetch(`/api/admin/users/${u.id}/role`, { method: 'POST', body: JSON.stringify({ role: newRole }) });
await load();
} catch (e) {
alert('Ошибка: ' + e.message);
} finally {
setPromoting(null);
}
};
const tierColors = { gold: 'gold', silver: 'celest', new: 'sage', refund: 'rose' };
const tierLabels = { gold: 'Gold (5к+)', silver: 'Silver', new: 'Новый', refund: 'Возврат' };
const totalPages = Math.max(1, Math.ceil(state.total / pageSize));
return (
{fmtNum(state.total)} пользователей · {fmtNum(state.stats.paying || 0)} платящих
Пользователи
{state.error &&
}
{!state.error && state.loading && state.users.length === 0 &&
}
{!state.error && !state.loading && state.users.length === 0 &&
}
{state.users.length > 0 && (
{['Пользователь', 'Регистрация', 'Заказов', 'LTV', 'Активность', 'Сегмент', 'Роль'].map((h, i) => (
{h}
))}
{state.users.map((u, i) => (
{(u.name || u.email || '?')[0].toUpperCase()}
{u.name || '—'}
{u.email}
{fmtDate(u.created_at)}
{u.orders_paid || 0}
{fmtRub(u.total_spent_rub || 0)}
{u.last_order_at ? fmtRelTime(u.last_order_at) : '—'}
{tierLabels[u.tier] || u.tier}
))}
)}
{state.total > 0 && (
{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, state.total)} из {fmtNum(state.total)}
{page} / {totalPages}
)}
);
};
// ============ AI LOGS ============
const AdminAI = () => {
const [state, setState] = React.useState({ loading: true, logs: [], stats: {}, error: null });
const load = React.useCallback(async () => {
setState(s => ({ ...s, loading: true, error: null }));
try {
const d = await adminFetch('/api/admin/ai?limit=200');
setState({ loading: false, logs: d.logs || [], stats: d.stats || {}, error: null });
} catch (e) {
setState({ loading: false, logs: [], stats: {}, error: e.message });
}
}, []);
React.useEffect(() => { load(); }, [load]);
const statusColor = { generating: 'gold', ready: 'sage', failed: 'rose', paid: 'gold', pending_payment: 'celest' };
const s = state.stats || {};
const fmtDur = (sec) => {
if (!sec) return '0с';
if (sec < 60) return `${Math.round(sec)}с`;
const m = Math.floor(sec / 60);
const ss = Math.round(sec - m * 60);
return `${m}:${String(ss).padStart(2, '0')}`;
};
return (
Swiss Ephemeris + DeepSeek R1
AI / Логи
{state.logs.length} записей
{state.error &&
}
{!state.error && state.loading && state.logs.length === 0 &&
}
{!state.error && !state.loading && state.logs.length === 0 &&
}
{state.logs.length > 0 && (
{state.logs.map((l, i) => (
{fmtDateTime(l.created_at)}
{l.order_id.slice(0, 8)}
{l.stage}
{fmtNum(l.tokens_sum)} t
{l.duration_ms ? Math.round(l.duration_ms / 1000) + 's' : '—'}
● {l.status}
))}
)}
);
};
// ============ A/B TESTS ============
const AdminABTests = () => {
const [state, setState] = React.useState({ loading: true, tests: [], error: null });
const load = React.useCallback(async () => {
setState(s => ({ ...s, loading: true, error: null }));
try {
const d = await adminFetch('/api/admin/abtests');
setState({ loading: false, tests: d.tests || [], error: null });
} catch (e) {
setState({ loading: false, tests: [], error: e.message });
}
}, []);
React.useEffect(() => { load(); }, [load]);
return (
{state.tests.filter(t => t.status === 'running').length} идут · {state.tests.filter(t => t.status !== 'running').length} завершено
A/B тесты
{state.error &&
}
{!state.error && state.loading &&
}
{!state.error && !state.loading && state.tests.length === 0 &&
}
{state.tests.length > 0 && (
{state.tests.map((t) => {
// Объединяем variants meta с stats по key
const statsByKey = {};
(t.stats || []).forEach(s => { statsByKey[s.key] = s; });
const variants = (t.variants || []).length
? t.variants.map(v => ({ ...v, ...(statsByKey[v.key] || {}) }))
: (t.stats || []);
const winner = t.winner || '';
const totalUsers = variants.reduce((a, v) => a + (v.users || 0), 0);
// Find best by conv_pct for visual highlight
let bestKey = null, bestConv = -1;
variants.forEach(v => { if ((v.conv_pct || 0) > bestConv) { bestConv = v.conv_pct || 0; bestKey = v.key; } });
return (
{t.name}
{t.status === 'running' ? 'идёт' : t.status}
key: {t.key} · {t.days || 0} дней · {fmtNum(totalUsers)} пользователей
{t.description &&
{t.description}
}
{variants.length === 0 &&
}
{variants.length > 0 && (
{variants.map((v, j) => {
const isWinner = winner === v.key || (winner === '' && v.key === bestKey && totalUsers > 0);
const isControl = j === 0;
const baseline = variants[0] ? (variants[0].conv_pct || 0) : 0;
const lift = j > 0 && baseline > 0 ? Math.round(((v.conv_pct || 0) - baseline) / baseline * 100) : null;
return (
{isWinner && (
лидер
)}
{v.name || v.key}
{(v.conv_pct || 0).toFixed(2)}
% конверсия
{fmtNum(v.users || 0)} юзеров · {fmtNum(v.purchases || 0)} конв.
{lift !== null && (
0 ? 'var(--sage)' : 'var(--rose)' }}>
{lift > 0 ? '+' : ''}{lift}%
)}
{isControl && контроль}
);
})}
)}
);
})}
)}
);
};
// ============ SUPPORT ============
const AdminSupport = () => {
const [state, setState] = React.useState({ loading: true, tickets: [], stats: {}, error: null });
const [filter, setFilter] = React.useState(''); // '', open, replied, closed
const [openTicket, setOpenTicket] = React.useState(null);
const load = React.useCallback(async () => {
setState(s => ({ ...s, loading: true, error: null }));
try {
const params = new URLSearchParams({ limit: '100' });
if (filter) params.set('status', filter);
const d = await adminFetch('/api/admin/support?' + params.toString());
setState({ loading: false, tickets: d.tickets || [], stats: d.stats || {}, error: null });
} catch (e) {
setState({ loading: false, tickets: [], stats: {}, error: e.message });
}
}, [filter]);
React.useEffect(() => { load(); }, [load]);
const s = state.stats || {};
return (
{s.open_count || 0} открытых · {s.high_open || 0} высокого приоритета
Поддержка
{openTicket ? (
{ setOpenTicket(null); load(); }} />
) : (
Тикеты
{[
{ id: '', label: 'Все' },
{ id: 'open', label: 'Открытые' },
{ id: 'replied', label: 'Отвечены' },
{ id: 'closed', label: 'Закрытые' },
].map(opt => (
))}
{state.error &&
}
{!state.error && state.loading && state.tickets.length === 0 &&
}
{!state.error && !state.loading && state.tickets.length === 0 &&
}
{state.tickets.map((t, i) => (
setOpenTicket(t.id)} style={{
display: 'grid',
gridTemplateColumns: '90px 90px 1.4fr 1.8fr 90px 90px',
gap: '16px',
padding: '18px 24px',
borderBottom: i < state.tickets.length - 1 ? '1px solid var(--line-soft)' : 'none',
alignItems: 'center',
cursor: 'pointer',
}}>
{t.id.slice(0, 8)}
{PRIO_LABELS[t.priority] || t.priority}
{t.user_name || '—'}
{t.user_email}
{t.subject}
{fmtRelTime(t.updated_at)}
{t.status === 'open' ? 'открыт' : t.status === 'replied' ? 'отвечен' : 'закрыт'}
))}
)}
);
};
const TicketDetailPanel = ({ ticketId, onClose }) => {
const [state, setState] = React.useState({ loading: true, ticket: null, messages: [], error: null });
const [reply, setReply] = React.useState('');
const [sending, setSending] = React.useState(false);
const load = React.useCallback(async () => {
setState(s => ({ ...s, loading: true, error: null }));
try {
const d = await adminFetch(`/api/admin/support/${ticketId}`);
setState({ loading: false, ticket: d.ticket, messages: d.messages || [], error: null });
} catch (e) {
setState({ loading: false, ticket: null, messages: [], error: e.message });
}
}, [ticketId]);
React.useEffect(() => { load(); }, [load]);
const sendReply = async () => {
const body = reply.trim();
if (!body) return;
setSending(true);
try {
await adminFetch(`/api/admin/support/${ticketId}/reply`, { method: 'POST', body: JSON.stringify({ body }) });
setReply('');
await load();
} catch (e) {
alert('Ошибка: ' + e.message);
} finally {
setSending(false);
}
};
const setStatus = async (status) => {
try {
await adminFetch(`/api/admin/support/${ticketId}/status`, { method: 'POST', body: JSON.stringify({ status }) });
await load();
} catch (e) {
alert('Ошибка: ' + e.message);
}
};
if (state.loading) return ;
if (state.error) return ;
const t = state.ticket;
if (!t) return null;
return (
{t.id.slice(0, 8)}
{PRIO_LABELS[t.priority] || t.priority}
{t.user_email}
{t.subject}
{state.messages.map(m => (
{m.author === 'admin' ? 'Админ' : m.author === 'user' ? 'Клиент' : 'Система'}
{fmtDateTime(m.created_at)}
{m.body}
))}
{state.messages.length === 0 &&
}
);
};
// ============ PRODUCTS ============
const AdminProducts = () => {
const [rows, setRows] = React.useState(null);
const [err, setErr] = React.useState(null);
const [savingKey, setSavingKey] = React.useState(null);
const [savedKey, setSavedKey] = React.useState(null);
const load = () => {
adminFetch('/api/admin/products')
.then(d => setRows(d.products || []))
.catch(e => setErr(e.message));
};
React.useEffect(load, []);
const patch = (key, field, value) => {
setRows(rows.map(r => r.key === key ? { ...r, [field]: value } : r));
};
const save = async (row) => {
setSavingKey(row.key); setErr(null);
try {
const d = await adminFetch(`/api/admin/products/${row.key}`, {
method: 'PUT',
body: JSON.stringify({ price_rub: Number(row.price_rub), enabled: !!row.enabled, title: row.title }),
});
setRows(d.products || rows);
setSavedKey(row.key); setTimeout(() => setSavedKey(null), 1800);
} catch (e) { setErr(e.message); } finally { setSavingKey(null); }
};
if (err && !rows) return Ошибка: {err}
;
if (!rows) return ;
return (
Цены тянутся отсюда: каталог, заказы, лендинги
{err &&
{err}
}
);
};
// ============ SETTINGS ============
const AdminSettings = () => {
const [rows, setRows] = React.useState(null);
const [err, setErr] = React.useState(null);
const [savingKey, setSavingKey] = React.useState(null);
const [savedKey, setSavedKey] = React.useState(null);
const load = () => {
adminFetch('/api/admin/settings')
.then(d => setRows(d.settings || []))
.catch(e => setErr(e.message));
};
React.useEffect(load, []);
const patch = (key, value) => setRows(rows.map(r => r.key === key ? { ...r, value } : r));
const save = async (row) => {
setSavingKey(row.key); setErr(null);
try {
const d = await adminFetch(`/api/admin/settings/${row.key}`, {
method: 'PUT', body: JSON.stringify({ value: row.value }),
});
setRows(d.settings || rows);
setSavedKey(row.key); setTimeout(() => setSavedKey(null), 1800);
} catch (e) { setErr(e.message); } finally { setSavingKey(null); }
};
if (err && !rows) return Ошибка: {err}
;
if (!rows) return ;
return (
Реквизиты и контакты
Настройки сайта
Расползаются по сайту: футеры, страница заказа, оферта, политика.
{err &&
{err}
}
{rows.map(row => (
))}
);
};
// ============ ROOT ============
const Admin = ({ subPage = 'dashboard', onSubPage }) => {
const [me, setMe] = React.useState(null);
React.useEffect(() => {
fetch('/api/auth/me', { credentials: 'same-origin' })
.then(r => r.ok ? r.json() : null)
.then(d => { if (d && d.email) setMe(d); })
.catch(() => {});
}, []);
return (
<>
{subPage === 'dashboard' && }
{subPage === 'orders' && }
{subPage === 'products' && }
{subPage === 'users' && }
{subPage === 'ai' && }
{subPage === 'abtests' && }
{subPage === 'support' && }
{subPage === 'settings' && }
>
);
};
Object.assign(window, { Admin });