// team.jsx — экран «Команда» (?view=team) для founder/moderator. Минимально и функционально:
// Кейсы — очередь на проверке: опубликовать (+баллы автору) / скрыть (баллы снимаются) /
// вернуть с комментарием; флажок «на сайт» — только если автор дал согласие
// Эфиры — отметить пришедших (+50) и ведущего (+100) по событию из data/events.json
// + флажок «Показывать на сайте» (неделя 3: уходит в GET /api/public/events)
// Подписки — у кого заканчивается подписка (3/7/14 дней) и истекла за 14 дней, ответ «Почему уходишь?», CSV
// Бонусы — кто что получил (manual — связаться самим)
// Ответы — «Почему уходишь?» и «Что получилось?» из сообщений Николая
// Тексты — сообщения Николая, бонусы, подписи тарифа, экран ухода (texts.jsx)
// Вошли без доступа — кто вошёл через Hakku ID без подписки: «Выдать доступ» дописывает строку в таблицу
// участников (источник lms-grant) и сразу синкает; Getplat: активные и «требует проверки» (сумма не из маппинга)
// Права проверяет сервер (/api/admin/*), здесь только кнопки.
const CONSENT_LABEL = { name: 'с именем и фото', anon: 'без имени', no: 'нет, только в сообществе' };
const fmtDT = (iso) => iso ? new Date(iso).toLocaleString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Moscow' }) : '';
function TeamCase({ c, onChanged, nav }) {
const [open, setOpen] = React.useState(false);
const [comment, setComment] = React.useState('');
const [site, setSite] = React.useState(!!c.on_site);
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState('');
const canSite = c.consent === 'name' || c.consent === 'anon';
const act = async (action) => {
if (action === 'return' && !comment.trim()) return setMsg('Напиши автору, что поправить.');
setBusy(true); setMsg('');
const r = await postJSON('/api/admin/cases/' + encodeURIComponent(c.id), { action, comment, on_site: canSite ? site : false });
setBusy(false);
if (!r.ok) return setMsg('Не получилось: ' + ((r.j && r.j.error) || 'ошибка сервера'));
const p = r.j.points;
toast(action === 'publish' ? `Опубликован${p ? `, автору +${p}` : p === 0 ? ', сверх лимита: 0 баллов' : ''}`
: action === 'hide' ? `Скрыт${p ? `, снято ${Math.abs(p)}` : ''}` : action === 'return' ? 'Вернули автору' : 'Сохранено');
onChanged();
};
const s = CASE_STATUS[c.status] || ['', ''];
return (
{s[0]} {fmtDT(c.created_at)}
{c.title}
{c.author.name} · {c.author.email} · сайт: {CONSENT_LABEL[c.consent] || '—'}{c.on_site ? ' · на сайте' : ''}
setOpen(o => !o)}>{open ? 'Свернуть' : 'Читать кейс'}
{open &&
}
{c.team_comment &&
Комментарий: {c.team_comment}
}
{c.status !== 'published' && (
setSite(e.target.checked)}/>
На сайт{canSite ? '' : ' (нет согласия)'}
)}
{c.status === 'published' && canSite && (
{ setBusy(true); const r = await postJSON('/api/admin/cases/' + c.id, { action: 'site', on_site: e.target.checked }); setBusy(false); if (r.ok) onChanged(); }}/>
На сайт
)}
{c.status !== 'published' && act('publish')}>Опубликовать }
{c.status !== 'hidden' && act('hide')}>Скрыть }
{c.status === 'pending' && act('return')}>Вернуть с комментарием }
{c.status !== 'published' && (
);
}
function TeamCases({ nav }) {
const [status, setStatus] = React.useState('pending');
const [data, reload] = useApi('/api/admin/cases?status=' + status);
return (
{[['pending', 'На проверке'], ['published', 'Опубликованы'], ['returned', 'Вернули'], ['hidden', 'Скрыты']].map(([k, t]) => (
setStatus(k)}>{t}{status === k && data ? ` · ${data.cases.length}` : ''}
))}
{data === undefined && Загружаем…
}
{data && !data.cases.length && {status === 'pending' ? 'Очередь пуста: новых кейсов на проверке нет.' : 'Здесь пусто.'}
}
{data && data.cases.map(c => )}
);
}
function TeamEvent({ e, members, onChanged }) {
const [editing, setEditing] = React.useState(false);
const [role, setRole] = React.useState('attended');
const [site, setSite] = React.useState(!!e.show_on_site);
const toggleSite = async (v) => {
setSite(v);
const r = await postJSON(`/api/admin/events/${encodeURIComponent(e.id)}/site`, { show_on_site: v });
if (!r.ok) { setSite(!v); return toast('Не сохранилось'); }
toast(v ? 'Событие уйдёт в расписание на сайте' : 'Событие убрано с сайта');
};
const upcoming = e.date >= new Date(Date.now() + 3 * 3600000).toISOString().slice(0, 10);
const [emails, setEmails] = React.useState('');
const [res, setRes] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const nameOf = (em) => ((members || []).find(m => m.email === em) || {}).name || em;
const add = (em) => { if (em) setEmails(x => (x.trim() ? x.trim() + '\n' : '') + em); };
const mark = async () => {
setBusy(true);
const r = await postJSON(`/api/admin/events/${encodeURIComponent(e.id)}/mark`, { role, emails });
setBusy(false);
if (!r.ok) return setRes([{ email: '', ok: false, error: 'ошибка сервера' }]);
setRes(r.j.results); setEmails(''); onChanged();
};
const annUrl = `${location.origin}/os/?view=schedule&id=${encodeURIComponent(e.id)}`;
const hide = async () => { const r = await postJSON(`/api/admin/events/${encodeURIComponent(e.id)}/hide`); if (r.ok) { toast('Эфир убран из расписания'); onChanged(); } };
if (editing) return { setEditing(false); onChanged(); }} onCancel={() => setEditing(false)}/>;
return (
{upcoming && }
setEditing(true)}>Изменить
Скрыть
{EV_KIND_LABEL[e.kind] || 'Событие'} {e.access === 'pro' && PRO }{fmtDay(e.date)} · {e.time || ''} МСК {e.link ? есть ссылка «Войти в эфир» : без ссылки }
{e.title}
Пришли ({e.attended.length}): {e.attended.length ? e.attended.map(nameOf).join(', ') : 'пока никого'}
Ведущий: {e.hosts.length ? e.hosts.map(nameOf).join(', ') : 'не отмечен'}
{upcoming && (
toggleSite(x.target.checked)}/>
Показывать на сайте{e.register_url ? 'регистрация по своей ссылке' : 'регистрация через бота'}
)}
{[['attended', 'Пришли · +50'], ['host', 'Ведущий · +100']].map(([k, t]) => (
setRole(k)}>{t} ))}
add(x.target.value)} aria-label="Добавить участника">
Добавить из таблицы…
{(members || []).map(m => {m.name || m.email} · {m.email} )}
);
}
// Форма анонса (правки Насти 23.10): добавить или поправить эфир без разработчика. Ссылка на Zoom наружу не уходит —
// участник получает её кнопкой «Войти в эфир» (проверка тарифа и +50 за присутствие).
const EV_ERR = { bad_date: 'Укажи дату', bad_time: 'Укажи время в формате 19:00', bad_title: 'Название — хотя бы 3 символа',
bad_link: 'Ссылка на эфир должна начинаться с https://', bad_register_url: 'Ссылка на регистрацию должна начинаться с https://', bad_kind: 'Выбери тип' };
function EventForm({ e, onDone, onCancel }) {
const [f, setF] = React.useState(() => ({ date: '', time: '19:00', kind: 'webinar', access: 'all', title: '', host: '', note: '',
link: '', register_url: '', duration_min: '', show_on_site: false, ...(e || {}) }));
const [err, setErr] = React.useState('');
const [busy, setBusy] = React.useState(false);
const set = (k) => (x) => setF({ ...f, [k]: x.target.type === 'checkbox' ? x.target.checked : x.target.value });
const save = async () => {
setBusy(true); setErr('');
const url = e ? `/api/admin/events/${encodeURIComponent(e.id)}` : '/api/admin/events';
const r = await fetch(url, { method: e ? 'PUT' : 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(f) })
.then(async x => ({ ok: x.ok, j: await x.json().catch(() => ({})) }));
setBusy(false);
if (!r.ok) return setErr(EV_ERR[r.j.error] || 'Не сохранилось. Попробуй ещё раз.');
toast(e ? 'Эфир обновлён' : 'Эфир добавлен в расписание');
onDone();
};
return (
);
}
function TeamEvents() {
const [data, reload] = useApi('/api/admin/events');
const [mem] = useApi('/api/admin/members');
const [adding, setAdding] = React.useState(false);
if (data === undefined) return Загружаем…
;
if (!data) return Не получилось загрузить. Обнови страницу.
;
const restore = async (id) => { const r = await postJSON(`/api/admin/events/${encodeURIComponent(id)}/restore`); if (r.ok) { toast('Эфир вернулся в расписание'); reload(); } };
return (
Анонс в Telegram давай ссылкой на эфир в LMS — она под каждым эфиром. Так LMS сама проверит тариф и отметит пришедших (+50).
{!adding &&
setAdding(true)}>+ Добавить эфир }
{adding && { setAdding(false); reload(); }} onCancel={() => setAdding(false)}/>}
{!data.events.length && !adding && Эфиров пока нет. Нажми «Добавить эфир».
}
{data.events.map(e => )}
{data.hidden && data.hidden.length > 0 && (
Скрыты: {data.hidden.map(h => {h.title} ({fmtDay(h.date)}) restore(h.id)}>вернуть {' '} )}
)}
);
}
function TeamClaims() {
const [data] = useApi('/api/admin/bonus-claims');
if (data === undefined) return Загружаем…
;
if (!data || !data.claims.length) return Подарки ещё никто не получал.
;
return (
{data.claims.map((c, i) => (
{fmtShortDay(c.claimed_at)}
{c.title} · {c.name || c.email} · {c.email}{c.code ? ` · код ${c.code}` : ''}{c.type === 'manual' ? ' · связаться' : ''}
{c.expires_at ? 'до ' + fmtShortDay(c.expires_at) : ''}
))}
);
}
// Баллы вручную (правки Насти 23.10): итоги месяца +30, баг/идея +30, встреча участников +350, консультация +300.
// Кто писал в Службу заботы — вставить почты (через запятую или с новой строки), выбрать за что, комментарий.
function TeamPoints() {
const [cfg] = useApi('/api/admin/points/actions');
const [action, setAction] = React.useState('');
const [emails, setEmails] = React.useState('');
const [comment, setComment] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [res, setRes] = React.useState(null);
if (cfg === undefined) return Загружаем…
;
if (!cfg) return Не получилось загрузить. Обнови страницу.
;
const act = cfg.actions.find(a => a.id === action) || null;
const send = async () => {
setBusy(true); setRes(null);
const r = await postJSON('/api/admin/points/award', { action, emails, comment });
setBusy(false);
if (!r.ok) { toast('Не получилось начислить'); return; }
setRes(r.j.results);
const got = r.j.results.filter(x => x.ok && x.points > 0).length;
toast(got ? `Начислено: ${got}` : 'Новых начислений нет');
if (got) setEmails('');
};
return (
Участник увидит начисление в «Подарки» → «История». Итоги месяца засчитываются один раз в месяц.
{cfg.actions.map(a => (
setAction(a.id)}>
{a.label} · +{a.points}
))}
{act &&
{act.rule}
}
Почты участников
);
}
// Рефералы: кто кого пригласил (по ссылке или кодом), статус и кнопки начисления бонуса.
// «Можно начислять» — приглашённый оплатил и прошёл срок возврата. Лимит — бонусов в месяц на пригласившего.
const REF_ST = { joined: ['перешёл', ''], paid: ['оплатил · ждём срок возврата', 'blue'], ready: ['можно начислять', 'green'], bonus: ['бонус начислен', 'mag'] };
function TeamReferrals() {
const [data, reload] = useApi('/api/admin/referrals');
const [busy, setBusy] = React.useState('');
if (data === undefined) return Загружаем…
;
if (!data || !data.referrals.length) return Пока никто не пришёл по реферальной ссылке.
;
const give = async (r, kind) => {
setBusy(r.referred);
const res = await postJSON('/api/admin/referrals/bonus', { referred: r.referred, kind });
setBusy('');
if (res.ok) { toast(`Бонус отмечен: ${r.referrer_name || r.referrer}`); reload && reload(); }
else toast(res.j && res.j.error === 'already' ? 'Бонус уже был отмечен' : 'Не получилось сохранить');
};
const order = { ready: 0, paid: 1, joined: 2, bonus: 3 };
const list = [...data.referrals].sort((a, b) => (order[a.status] - order[b.status]) || String(b.at).localeCompare(String(a.at)));
return (<>
Бонус начисляете вы: месяц бесплатно или скидку пригласившему — в боте или Getplat, потом отметка здесь.
Срок возврата — {data.hold_days} дней с оплаты. Лимит — {data.monthly_limit} в месяц на пригласившего.
{list.map(r => {
const [label, tone] = REF_ST[r.status] || [r.label, ''];
return (
{fmtShortDay(r.at)}
{r.referrer_name || r.referrer} пригласил(а) {r.referred_name || r.referred}
· {r.referrer} → {r.referred} · {r.source === 'manual' ? 'ввёл код' : 'по ссылке'}
{r.paid_since ? ` · оплата ${fmtShortDay(r.paid_since)}` : ''}{r.status === 'paid' && r.ready_at ? ` · начислять с ${fmtShortDay(r.ready_at)}` : ''}
{r.bonus_kind ? ` · ${r.bonus_kind}` : ''}{r.status === 'ready' && r.limit_reached ? ' · лимит месяца исчерпан' : ''}
{label}
{r.status === 'ready' && (data.bonus_kinds || []).map(k => (
give(r, k)}>{k}
))}
);
})}
>);
}
// «Почему уходишь?»: причина, комментарий, что предложили, по чему кликнул, отказался ли
function churnLine(c) {
if (!c) return '';
return [c.offer_titles && c.offer_titles.length ? 'предложили: ' + c.offer_titles.join(', ') : '',
c.clicked_title ? `выбрал «${c.clicked_title}»` : '', c.declined ? 'всё равно не продлевает' : ''].filter(Boolean).join(' · ');
}
function TeamChurn() {
const [data] = useApi('/api/admin/churn');
if (data === undefined) return Загружаем…
;
if (!data || !data.answers.length) return На «Почему уходишь?» пока никто не отвечал.
;
return (
{data.answers.map(a => (
{fmtDT(a.created_at)} · {a.name || 'без имени'} · {a.email}{a.paid_until ? ` · оплачено до ${fmtDateShort(a.paid_until)}` : ''}
{a.reason_label} {a.comment ? ` — ${a.comment}` : ''}
{churnLine(a) || 'пока не выбрал предложение'}
))}
);
}
function TeamAnswers() {
return (
Почему уходишь?
Что получилось?
);
}
function TeamAsk() {
const [data] = useApi('/api/admin/answers');
if (data === undefined) return Загружаем…
;
if (!data || !data.answers.length) return Ответов на «Что получилось?» пока нет.
;
return (
{data.answers.map((a, i) => (
{fmtDT(a.created_at)} · {a.name || 'без имени'} · {a.email}
{a.answer}
))}
);
}
// «У кого заканчивается подписка»: ближайшие 3/7/14 дней и истёкшие за 14 дней, по дате
const daysLabel = (n) => n > 0 ? `через ${daysText(n)}` : n === 0 ? 'сегодня' : `${daysText(-n)} назад`;
function TeamExpiring() {
const [days, setDays] = React.useState(7);
const [data] = useApi('/api/admin/expiring?days=' + days);
const soon = data ? data.rows.filter(r => !r.expired) : [], gone = data ? data.rows.filter(r => r.expired) : [];
const row = (r) => (
{r.name || r.email} {r.email}
{r.telegram || — }
{TIER_LABEL[r.tariff] || r.tariff}{r.status && r.status !== 'active' ? {STATUS_LABEL[r.status] || r.status} : null}
{fmtDateShort(r.paid_until)}{daysLabel(r.days)}
{r.churn
? <>{r.churn.reason_label} {r.churn.comment ? «{r.churn.comment}» : null}{churnLine(r.churn) || 'не выбрал предложение'} >
: не отвечал }
);
return (
{[3, 7, 14].map(n => setDays(n)}>{n} {plural(n, 'день', 'дня', 'дней')} )}
Скачать CSV
{data === undefined && Загружаем…
}
{data === null && Список не загрузился. Обнови страницу через минуту.
}
{data && (
<>
Заканчивается в ближайшие {daysText(days)} · {soon.length}
{soon.length ?
Участник Telegram Тариф Оплачено до Почему уходишь?
{soon.map(row)}
: Ни у кого не заканчивается.
}
Закончилась за последние {data.past} дней · {gone.length}
{gone.length ?
Участник Telegram Тариф Оплачено до Почему уходишь?
{gone.map(row)}
: Никто не ушёл за две недели.
}
Даты — из таблицы участников, обновляются раз в 5 минут. Ответ «Почему уходишь?» показываем, если он про этот срок оплаты.
>
)}
);
}
// ---- «Вошли без доступа» --------------------------------------------------------------------
const GP_STATE = { none: 'в Getplat не найден', expired: 'в Getplat есть, подписка закончилась', active: 'в Getplat активна', off: 'Getplat не подключён' };
const PERIOD_SHORT = { month: 'мес', year: 'год' };
const GRANT_ERR = { already_member: 'У этой почты уже есть доступ.', bad_until: 'Дата «до» — не раньше сегодняшней.', sheet: 'Таблица не ответила — строка не записана. Попробуй ещё раз.',
bad_email: 'Странная почта.', bad_tariff: 'Выбери тариф.' };
function TeamGrant({ r, defUntil, onDone }) {
const [tariff, setTariff] = React.useState('enthusiast');
const [until, setUntil] = React.useState(defUntil);
const [forever, setForever] = React.useState(false);
const [comment, setComment] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState('');
const grant = async () => {
setBusy(true); setErr('');
const x = await postJSON('/api/admin/access/grant', { email: r.email, tariff, until, forever, comment });
setBusy(false);
if (!x.ok) return setErr(GRANT_ERR[x.j && x.j.error] || 'Не получилось: ошибка сервера.');
toast(x.j.active ? `Доступ открыт: ${TIER_LABEL[tariff]}${forever ? ', бессрочно' : ' до ' + fmtDateShort(until)}` : 'Строка записана, доступ появится после синка таблицы');
onDone();
};
return (
setComment(e.target.value)} maxLength={200}
placeholder="Комментарий в таблицу: за что или откуда" aria-label="Комментарий"/>
{busy ? 'Записываем…' : 'Выдать доступ'}
{err &&
{err}
}
);
}
function TeamDenied({ r, defUntil, onDone }) {
const granted = !!r.granted_at;
return (
{GP_STATE[r.getplat] || 'в Getplat не найден'}
{r.state === 'expired' && в таблице, срок истёк }
{r.visits} {plural(r.visits, 'заход', 'захода', 'заходов')}
{r.name || r.email}
{r.email}{r.aliases && r.aliases.length ? ` · почты оплаты: ${r.aliases.join(', ')}` : ''}
Первый вход {fmtDT(r.first_at)} · последний {fmtDT(r.last_at)}
{granted
?
{Icons.check()}Доступ выдан: {TIER_LABEL[r.granted_tariff] || r.granted_tariff}
{r.granted_until === '2099-12-31' ? ', бессрочно' : r.granted_until ? ` до ${fmtDateShort(r.granted_until)}` : ''} · {r.granted_by} · {fmtDT(r.granted_at)}
{r.has_access ? '' : ' · ждём синка таблицы'}
:
}
);
}
function GpRow({ s }) {
return (
{s.email || '—'} {s.offer || 'без названия'}
{TIER_LABEL[s.tariff] || s.tariff}{s.review ? проверить : null}
{fmtRub(s.amount)}{PERIOD_SHORT[s.period] ? за {PERIOD_SHORT[s.period]} : {s.period} }
{fmtDateShort(s.paid_until)}{s.is_active ? 'автопродление' : 'отменена, дослуживает'}
);
}
function GpTable({ rows, limit = 0 }) {
const [all, setAll] = React.useState(false);
const list = limit && !all ? rows.slice(0, limit) : rows;
return (
<>
Почта · предложение Тариф Сумма Оплачено до
{list.map(s =>
)}
{limit > 0 && rows.length > limit && setAll(a => !a)}>{all ? 'Свернуть' : `Показать все ${rows.length}`} }
>
);
}
function TeamAccess() {
const [data, reload] = useApi('/api/admin/access');
const [syncing, setSyncing] = React.useState(false);
const sync = async () => {
setSyncing(true);
const r = await postJSON('/api/admin/getplat/sync');
if (!r.ok) { setSyncing(false); return toast('Синк не запустился'); }
toast('Синк Getplat запущен — список обновится через минуту');
setTimeout(() => { setSyncing(false); reload(); }, 15000);
};
if (data === undefined) return Загружаем…
;
if (!data) return Список не загрузился. Обнови страницу через минуту.
;
const gp = data.getplat;
const waiting = data.denied.filter(r => !r.granted_at), done = data.denied.filter(r => r.granted_at);
return (
{gp.enabled ? gp.active_count : '—'} Getplat: активные
{gp.enabled ? gp.review.length : '—'} требует проверки
{!gp.enabled ? 'Getplat не подключён: нет GETPLAT_API_KEY.' : gp.running ? 'Синк идёт…'
: gp.synced_at ? `Синк ${fmtDT(gp.synced_at)}${gp.error ? ' · последний с ошибкой' : ''}${gp.mock ? ' · мок' : ''}` : 'Синка ещё не было.'}
{gp.enabled && {syncing ? 'Синкаем…' : 'Синхронизировать'} }
Вошли без доступа · {waiting.length}
{waiting.length ? waiting.map(r => )
: Все, кто входил, уже с доступом.
}
«Выдать доступ» дописывает строку в таблицу участников (источник lms-grant, комментарий «выдал …») и сразу её перечитывает. Бессрочно — «оплачено до» 31.12.2099, роль member: команду назначают в таблице.
{done.length > 0 && <>
Выдали за 2 недели · {done.length}
{done.map(r => )}
>}
Getplat: требует проверки · {gp.review.length}
{gp.review.length ? : Все суммы активных подписок есть в маппинге.
}
{gp.review.length > 0 && Сумма не из data/getplat_tariffs.json — доступ открыт по тарифу из столбца (по умолчанию Enthusiast, «ПРО» в названии — PRO). Проверь и допиши сумму в маппинг.
}
Getplat: активные · {gp.active.length}
{gp.active.length ? : Активных подписок нет.
}
);
}
const TEAM_TABS = [['cases', 'Кейсы'], ['events', 'Эфиры'], ['expiring', 'Подписки'], ['access', 'Вошли без доступа'], ['claims', 'Подарки'], ['points', 'Баллы'], ['referrals', 'Рефералы'], ['answers', 'Ответы'], ['texts', 'Тексты']];
function Team({ nav, params = {} }) {
const [tab, setTab] = React.useState(TEAM_TABS.some(([k]) => k === params.tab) ? params.tab : 'cases');
const go = (k) => { setTab(k); try { history.replaceState(history.state, '', '/os/?view=team&tab=' + k); } catch (e) {} };
return (
Команда
Для команды сообщества
Проверка кейсов, эфиры и расписание на сайте, у кого заканчивается подписка, кто вошёл без доступа, бонусы, ответы участников и тексты.
{TEAM_TABS.map(([k, t]) => (
go(k)}>{t}
))}
{tab === 'cases' &&
}
{tab === 'events' && }
{tab === 'expiring' && }
{tab === 'access' && }
{tab === 'claims' && }
{tab === 'referrals' && }
{tab === 'points' && }
{tab === 'answers' && }
{tab === 'texts' && }
);
}
window.Team = Team;