// admin.jsx — Admin dashboard, orders/users tables, AI logs, A/B tests, support. // Wired to /api/admin/* JSON endpoints. UI на русском, design сохранён без изменений. // Фильтры таблицы заказов. Пустое значение = «без фильтра», поэтому первым // пунктом идёт сброс, а не отсутствие выбора. const ADMIN_SERVICE_OPTIONS = [ { value: '', label: 'Все продукты' }, { value: 'natal', label: 'Натальная карта' }, { value: 'year_ahead', label: 'Прогноз на год' }, { value: 'compatibility', label: 'Совместимость' }, { value: 'matrix', label: 'Матрица судьбы' }, { value: 'personal_forecast', label: 'Индивидуальный гороскоп' }, ]; const ADMIN_STATUS_OPTIONS = [ { value: '', label: 'Любой статус' }, { value: 'ready', label: 'Готов' }, { value: 'generating', label: 'Генерируется' }, { value: 'paid', label: 'Оплачен' }, { value: 'pending_payment', label: 'Ожидает оплаты' }, { value: 'failed', label: 'Ошибка' }, { value: 'refunded', label: 'Возврат' }, ]; // ============ 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') + ' ₽'; }; // USD-цены хранятся в центах, как копейки у рубля: в форме вводим доллары // («29.90»), в API уходят центы (2990). Пустой ввод = 0 — за USD не продаём. const usdFromCents = (c) => (c ? (c / 100).toFixed(2) : ''); const centsFromUsd = (s) => { const n = parseFloat(String(s || '').replace(',', '.')); return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : 0; }; // Инпут держит строку, пока её печатают: «29.» — ещё не число, и конвертировать // её в центы на каждый символ значило бы съедать точку из-под курсора. const usdInputValue = (row) => (row.price_usd_input !== undefined ? row.price_usd_input : usdFromCents(row.price_usd)); const usdCentsFromRow = (row) => centsFromUsd(usdInputValue(row)); 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)} дн назад`; }; // Имя не fmtDate: app.js объявляет свой fmtDate в глобальной области, // а оба файла теперь грузятся на одной странице (SPA). const fmtDateDay = (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 ADMIN_NAV = [ { title: 'Аналитика', items: [ { id: 'dashboard', label: 'Дашборд' }, { id: 'analytics', label: 'Аналитика' }, { id: 'funnel', label: 'Воронка' }, { id: 'audience', label: 'Аудитория' }, ] }, { title: 'Продажи', items: [ { id: 'orders', label: 'Заказы' }, { id: 'products', label: 'Продукты' }, { id: 'promo', label: 'Промокоды' }, ] }, { title: 'Люди', items: [ { id: 'users', label: 'Пользователи' }, { id: 'support', label: 'Поддержка' }, { id: 'reviews', label: 'Отзывы' }, ] }, { title: 'Система', items: [ { id: 'ai', label: 'AI / Логи' }, { id: 'abtests', label: 'A/B тесты' }, { id: 'feedkeys', label: 'Ключи фида' }, { id: 'settings', label: 'Настройки' }, ] }, ]; const AdminSideNav = ({ subPage, onSubPage, currentUser }) => { const email = currentUser?.email || 'admin@star-path.ru'; const initial = (email[0] || 'A').toUpperCase(); const brand = ( Star Path ); const footer = ( <>
{initial}
{email}
); return ( ); }; 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}%
))}
); }; // BotSources — откуда пришли подписчики бота за 30 дней. // // Метку кладёт start-параметр ссылки (?start=tt_video17). «Живых» и // «с аккаунтом» показываем в той же строке: метка, приводящая людей, которые // отписываются в тот же день, — это не работающая метка, и по одному общему // числу её не отличить от работающей. const BotSources = ({ rows }) => { const data = rows || []; return (
Подписчики бота
30 дней · по меткам ссылок
{data.length === 0 && } {data.length > 0 && (
ИСТОЧНИК ВСЕГО · ЖИВЫХ · С АККАУНТОМ
{data.map((r, i) => (
{/* Пустая метка — это «пришёл сам»: ссылкой без хвоста, поиском по названию бота или из кабинета. Не то же, что метка с пустым значением, поэтому и подписано словами. */} {r.source || 'без метки'} {fmtNum(r.total)} · {fmtNum(r.live)} · {fmtNum(r.linked)}
))}
)}
); }; 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 ============ // orderIDFromPath — id заказа из адреса /admin/orders/{id}. // // По этому адресу приходят из алертов: письмо про упавший заказ ведёт прямо // сюда. Раньше SPA показывала дашборд, потому что подпуть никто не читал, и // «открыть заказ» из письма означало искать его в списке руками. const orderIDFromPath = () => { const m = /^\/admin\/orders\/([0-9a-fA-F-]{36})/.exec(location.pathname); return m ? m[1] : ''; }; // ticketIDFromPath — id тикета из адреса /admin/support/{id}. // // Ровно как у заказа: алерт «новый тикет» ведёт этой ссылкой, и до сих пор она // открывала общий список — админ читал письмо про конкретное обращение и шёл // искать его глазами. const ticketIDFromPath = () => { const m = /^\/admin\/support\/([0-9a-fA-F-]{36})/.exec(location.pathname); return m ? m[1] : ''; }; // BURN_RU — почему модель звали лишний раз. Термины кода («short», «judge_call») // в интерфейсе не читаются: смотреть в карточку будут не по коду, а по счёту. const BURN_RU = { judge_call: 'вычитка', judge_rewrite: 'переписано по вычитке', short: 'коротко', parse: 'сломанный JSON', cjk: 'иероглифы', facts: 'разошлось с картой', unknown: 'прочее', }; const burnSummary = (x) => { const r = x.burn_reasons || {}; const parts = Object.keys(r).filter(k => r[k] > 0) .sort((a, b) => r[b] - r[a]) .map(k => (BURN_RU[k] || k) + (r[k] > 1 ? ' ×' + r[k] : '')); const money = x.cost_burnt_usd ? ' · $' + Number(x.cost_burnt_usd).toFixed(4) : ''; return parts.length ? parts.join(', ') + money : '—'; }; // AdminOrderDetail — карточка заказа: что заказали, что сгенерилось и что упало. // // Главное здесь — таблица секций с попытками и текстом ошибки. Заказ падает // посекционно, и без неё видно только итог («permanent failure in 1 sections»), // по которому нельзя ни понять причину, ни решить, чинится ли она повтором. const AdminOrderDetail = ({ id, onBack }) => { const [state, setState] = React.useState({ loading: true, data: null, error: null }); const [busy, setBusy] = React.useState(''); const load = React.useCallback(async () => { try { const d = await adminFetch('/api/admin/orders/' + id); setState({ loading: false, data: d, error: null }); } catch (e) { setState({ loading: false, data: null, error: e.message }); } }, [id]); React.useEffect(() => { load(); }, [load]); // Пока заказ считается — сам обновляемся: админ пришёл по алерту смотреть, // добьётся ли ретрай успеха, и F5 в этом месте лишний. const status = state.data && state.data.order && state.data.order.status; React.useEffect(() => { if (status !== 'generating' && status !== 'paid') return; const t = setInterval(load, 5000); return () => clearInterval(t); }, [status, load]); const act = async (endpoint, body) => { setBusy(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 { setBusy(''); } }; if (state.loading) return
; if (state.error) return
; const o = state.data.order || {}; const secs = state.data.sections || []; const user = state.data.user || {}; const color = ORDER_STATUS_COLORS[o.status] || 'celest'; const problem = secs.filter(x => x.status === 'failed' || (x.error_message || '').length > 0); const field = (label, value) => (
{label}
{value || —}
); return (
{o.service_ru} · {fmtRub(o.amount_rub)}

{(state.data.person_name) || (user.name || user.email || 'Заказ')}

{o.id}
{o.status_ru}
{o.error_message && (
Ошибка заказа
{o.error_message}
)}
{field('Клиент', user.email)} {field('Создан', fmtDateTime(o.created_at))} {field('Оплачен', o.paid_at ? fmtDateTime(o.paid_at) : null)} {field('Готов', o.ready_at ? fmtDateTime(o.ready_at) : null)} {field('Секций готово', state.data.sections_ready + ' / ' + state.data.sections_total)} {field('Модель', o.llm_model)} {field('Токенов', o.llm_tokens_in || o.llm_tokens_out ? (o.llm_tokens_in + ' / ' + o.llm_tokens_out) : null)} {field('Стоимость LLM', o.llm_cost_usd ? ('$' + Number(o.llm_cost_usd).toFixed(3)) : null)}
{problem.length > 0 && ( )} {o.status === 'ready' && ( <> )} {o.pdf_url && Скачать PDF} Открыть как клиент

Секции

{['#', 'Ключ', 'Статус', 'Попыт.', 'Ошибка', 'Лишние вызовы', 'Время'].map((h, i) => ( {h} ))}
{secs.map((x, i) => { const bad = x.status === 'failed'; return (
{x.position} {x.key} {x.status_ru || x.status} {x.attempts} {x.error_message || '—'} {/* На чём секция потратила лишние вызовы модели: вычитка, слишком короткий ответ, сломанный JSON. Три четверти счёта уходит сюда, и без разбивки непонятно, что именно чинить. */} {burnSummary(x)} {x.duration_ms ? Math.round(x.duration_ms / 1000) + ' с' : '—'}
); })}
); }; const AdminOrders = () => { // Открытый заказ живёт в адресе: ссылка из алерта, обновление страницы и // кнопка «назад» в браузере должны вести туда же, куда клик по строке. const [openID, setOpenID] = React.useState(orderIDFromPath); React.useEffect(() => { const onPop = () => setOpenID(orderIDFromPath()); window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, []); const openOrder = (oid) => { history.pushState(null, '', '/admin/orders/' + oid); setOpenID(oid); }; const closeOrder = () => { history.pushState(null, '', '/admin/orders'); setOpenID(''); }; 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)); if (openID) return ; return (
{fmtNum(state.total)} заказов всего

Заказы

{ setQ(e.target.value); setPage(1); }} style={{ paddingLeft: 36 }} />
{ setStatus(v); setPage(1); }} options={ADMIN_STATUS_OPTIONS} />
{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.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}
{fmtDateDay(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 // Тикет из адреса открываем сразу: по /admin/support/{id} приходят из алерта. const [openTicket, setOpenTicket] = React.useState(() => ticketIDFromPath() || 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 ? ( { if (ticketIDFromPath()) window.history.replaceState({}, '', '/admin/support'); 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.guest &&
без аккаунта · ответ письмом
}
{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.guest && без аккаунта · ответ уйдёт письмом} {t.source_page && со страницы {t.source_page}}
{t.subject}
{state.messages.map(m => (
{m.author === 'admin' ? 'Админ' : m.author === 'user' ? 'Клиент' : 'Система'} {fmtDateTime(m.created_at)}
{m.body}
))} {state.messages.length === 0 && }