// 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 && ( 1 ? sparkW : sparkW / 2} cy={sparkH - ((arr[arr.length - 1] - min) / range) * sparkH} r="2.5" fill={positive ? 'var(--gold)' : 'var(--rose)'} /> )}
); }; // ============ 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.length > 1 && ( <> `${(i / (data.length - 1)) * 100},${100 - (v / max) * 100}`).join(' ')} 100,100`} fill="url(#rev-grad)" /> `${(i / (data.length - 1)) * 100},${100 - (v / max) * 100}`).join(' ')} fill="none" stroke="var(--gold)" strokeWidth="0.4" vectorEffect="non-scaling-stroke" /> )}
{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 len = (d.val / 100) * c; const segment = ( ); acc += len + 2; return segment; })}
{data.map((d, i) => (
{d.name}
{d.val}%
))}
); }; 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)} заказов всего

Заказы

{ setQ(e.target.value); setPage(1); }} style={{ paddingLeft: 36 }} />
{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)} платящих

Пользователи

{ setQ(e.target.value); setPage(1); }} style={{ paddingLeft: 36 }} />
{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 / Логи

Журнал LLM-вызовов
{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 && }