// points.jsx — баллы, бонусы и лидеры (CJM Насти, п. 3.4–3.5; решения Саши 02.10.2026).
// Bonuses — экран ?view=bonuses: витрина, «Как работают баллы», «Мои бонусы», история
// Leaders — экран ?view=leaders: месяц / за всё время, своя плашка, Топ-10, «не показывать меня»
// PointsChips, BonusMini, TopMini — блоки главной
// Баллы только копятся: «Получить» бонус не тратит баланс, бонус открывается по порогу,
// каждый — один раз. Рейтинг считает те же накопленные баллы. Всё решает сервер (/api/points,
// /api/bonuses, /api/leaders), здесь только рисуем.
// GET с перезагрузкой: [data | undefined, reload]
function useApi(url, deps = []) {
const [data, setData] = React.useState(undefined);
const [n, setN] = React.useState(0);
React.useEffect(() => {
if (!url) return;
let alive = true;
fetch(url, { credentials: 'same-origin' }).then(r => r.json()).then(j => alive && setData(j && j.ok ? j : null))
.catch(() => alive && setData(null));
return () => { alive = false; };
}, [url, n, ...deps]);
return [data, () => setN(x => x + 1)];
}
const putJSON = (url, body) => fetch(url, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) }).then(r => r.json().catch(() => null).then(j => ({ ok: r.ok && j && j.ok, j }))).catch(() => ({ ok: false, j: null }));
// свежий /api/auth/me — после «Недели PRO» меняется уровень доступа
const refreshMe = () => fetch('/api/auth/me', { credentials: 'same-origin' }).then(r => r.json())
.then(j => { if (j && j.ok) { window.__ME = j; setPoints(j.points); } }).catch(() => {});
const MONTH_NOM = ['январь', 'февраль', 'март', 'апрель', 'май', 'июнь', 'июль', 'август', 'сентябрь', 'октябрь', 'ноябрь', 'декабрь'];
const MONTH_GEN = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
const MONTH_PREP = ['январе', 'феврале', 'марте', 'апреле', 'мае', 'июне', 'июле', 'августе', 'сентябре', 'октябре', 'ноябре', 'декабре'];
const monthIdx = (period) => Number(String(period || '').slice(5, 7)) - 1;
const curPeriod = () => new Date(Date.now() + 3 * 3600000).toISOString().slice(0, 7);
const ballov = (n) => plural(n, 'балл', 'балла', 'баллов');
const fmtShortDay = (iso) => new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', timeZone: 'Europe/Moscow' }).replace('.', '');
const fmtUntil = (iso) => new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', timeZone: 'Europe/Moscow' });
/* ---- чипы приветствия: баллы и место ------------------------------ */
function PointsChips({ leaders, nav }) {
const total = usePointsTotal();
const me = leaders && leaders.me;
const month = MONTH_PREP[monthIdx(leaders ? leaders.period : curPeriod())];
let place;
if (!leaders) place = null;
else if (me && me.team) place = 'Команда вне рейтинга';
else if (me && me.place) place = <>{me.place} место в {month}>;
else place = 'Рейтинг: пока без места';
return (
nav('bonuses')}> {fmtNum(total)} {ballov(total)}
{place && nav('leaders')}>{Icons.trophy()} {place} }
);
}
/* ---- получение бонуса --------------------------------------------- */
function useClaim(onDone) {
const [b, setB] = React.useState(null);
const modal = b ? setB(null)} onDone={(res) => { setB(null); onDone && onDone(res); }}/> : null;
return [setB, modal];
}
function ClaimModal({ b, onClose, onDone }) {
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState('');
const total = getPoints();
React.useEffect(() => {
const k = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
}, []);
const go = async () => {
setBusy(true); setErr('');
const r = await postJSON(`/api/bonuses/${encodeURIComponent(b.id)}/claim`);
setBusy(false);
if (!r.ok) {
const e = r.j && r.j.error;
return setErr(e === 'soldout' ? 'Этот подарок только что закончился.' : e === 'already' ? 'Ты уже получил этот бонус.'
: e === 'not_enough' ? `Не хватает ${r.j.need} ${ballov(r.j.need)}.` : 'Не получилось, попробуй ещё раз.');
}
if (b.type === 'pro_week') await refreshMe();
const until = r.j.bonus.expires_at ? ` до ${fmtUntil(r.j.bonus.expires_at)}` : '';
toast(`«${b.title}» твой${until}`, { label: 'Мои подарки', onClick: () => window.__NAV && window.__NAV('bonuses', { tab: 'mine' }) });
onDone(r.j.bonus);
};
return (
e.stopPropagation()}>
Забрать подарок?
{Icons.x()}
{b.title}
{b.gives}{b.type === 'pro_week' ? ' Неделя начнётся сразу.' : b.type === 'manual' ? ' Команда свяжется с тобой сама.' : ''}
Баллов останется {fmtNum(total)}
Баллы не тратятся: подарок открылся, потому что ты набрал {fmtNum(b.threshold)}. Место в рейтинге не изменится.
{err &&
{err}
}
Отмена
{busy ? 'Получаем…' : 'Получить'}
);
}
const STATE_BADGE = { available: ['Доступно', 'green'], claimed: ['Получено', 'ink'], soldout: ['Закончились', ''], included: ['В тарифе', 'mag'] };
function Bar({ value, max }) {
const p = Math.max(0, Math.min(100, max ? (value / max) * 100 : 0));
return
;
}
const leftText2 = (b) => b.left == null ? 'Без ограничений' : b.limit != null ? `Осталось ${b.left} из ${b.limit}` : `Осталось ${b.left}`;
function BonusCard({ b, total, onClaim, onHow }) {
const badge = STATE_BADGE[b.state];
return (
{(Icons[b.icon] || Icons.gift)()}
{badge && {badge[0]} }
{b.title}
{b.gives}
{fmtNum(b.threshold)} {ballov(b.threshold)}{leftText2(b)}
{b.state === 'available' &&
onClaim(b)}>Получить }
{b.state === 'locked' && (
Ещё {fmtNum(b.need)} {ballov(b.need)} {fmtNum(total)} / {fmtNum(b.threshold)}
)}
{b.state === 'claimed' &&
Получено · Как использовать
}
{b.state === 'soldout' &&
Закончились. Новые добавим в начале месяца.
}
{b.state === 'included' &&
Уже входит в твой тариф PRO.
}
);
}
/* ---- превью витрины на главной ------------------------------------ */
function BonusMini({ data, nav, onClaimed }) {
const total = usePointsTotal();
const [claim, modal] = useClaim(onClaimed);
if (!data) return null;
const items = data.items.slice(0, 3);
return (
{fmtNum(total)} {ballov(total)}
{total > 0
?
Доступно сейчас: {data.available} из {data.count}
:
Первые баллы дают дочитанный материал (+5–15) и эфир (+{(CFG().points || {}).attend || 50}).
}
{items.map(b => (
{(Icons[b.icon] || Icons.gift)()}
{b.title}
{b.state === 'locked' && <>
Ещё {fmtNum(b.need)} {ballov(b.need)}
>}
{b.state === 'claimed' && Получено
}
{b.state === 'soldout' && Закончились
}
{b.state === 'included' && В твоём тарифе
}
{b.state === 'available' && {fmtNum(b.threshold)} {ballov(b.threshold)}
}
{b.state === 'available' &&
claim(b)}>Получить }
))}
nav('bonuses')}>Все подарки →
{modal}
);
}
/* ---- строки рейтинга ------------------------------------------------ */
function LeaderRow({ r, wide = false, period }) {
return (
{r.place}
{r.me ? (wide ? `Ты · ${r.name}` : 'Ты') : r.name}
{wide && r.sphere && {r.sphere} }
{wide && {r.sphere} }
{fmtNum(r.points)}
);
}
function meLine(me, period) {
if (!me) return '';
if (me.team) return 'Команда и основатели в рейтинг не входят.';
if (me.hidden && me.place) return 'Ты скрыт из рейтинга, место видно только тебе.';
if (!me.place) return 'Тебя пока нет в рейтинге. Первые баллы, и появится твоё место.';
if (me.place === 1) return 'Ты на первом месте. Так держать.';
return `Ещё ${fmtNum(me.need)} ${ballov(me.need)}, и ты на ${me.next_place}-м`;
}
/* ---- Топ-5 месяца на главной ----------------------------------------- */
function TopMini({ data, nav, n = 5 }) {
if (!data) return null;
const top = data.top.slice(0, n);
const meIn = top.some(r => r.me);
const meRow = !meIn && data.me && data.me.place ? { place: data.me.place, points: data.me.points, me: true, name: ME().user.name, initials: null } : null;
return (
{top.length ? top.map(r =>
) : Месяц только начался: первые баллы ещё впереди.
}
{meRow && <>· · ·
>}
{meLine(data.me)}
);
}
/* ---- экран «Подарки» (баллы и витрина) ---------------------------------------------------- */
function PointsRules({ rules }) {
if (!rules) return null;
const pts = (r) => r.levels ? `+${r.levels.join(' / ')}` : `+${r.points}`;
return (
За что Условие Баллы
{rules.map(r => (
{r.label} {r.rule} {pts(r)}
))}
);
}
// Пояснение под таблицей (правка Насти 05.10.2026): текст и строка про сгорание — из data/points.json
function PointsNote({ pts }) {
if (!pts) return null;
return (
{pts.note &&
{pts.note}
}
{pts.expire_note &&
{pts.expire_note}
}
);
}
function History({ list }) {
if (!list || !list.length) return (
История пустая
Здесь будет каждое начисление: за что, когда и сколько.
);
return (
{list.map((x, i) => (
{fmtShortDay(x.created_at)}
{x.label}{x.note ? ' ' + x.note : ''}
{x.points > 0 ? '+' : x.points < 0 ? '−' : ''}{Math.abs(x.points)}
))}
);
}
function MyBonuses({ list, nav, compact = false }) {
if (!list || !list.length) return (
Пока ни одного подарка
Подарки открываются, когда набираешь их порог. Быстрее всего: эфир (+50) и кейс по шаблону (+100).
{!compact &&
nav('kb')}>Открыть базу знаний }
);
return (
{list.map(b => (
{(Icons[b.icon] || Icons.gift)()}
{b.title}
Получен {fmtUntil(b.claimed_at)}{b.expires_at ? ` · действует до ${fmtUntil(b.expires_at)}` : ''}
{b.code && (
{b.code}
)}
{b.how &&
Как использовать: {b.how}
}
))}
);
}
function Bonuses({ nav, params = {} }) {
window.__NAV = nav;
const [data, reload] = useApi('/api/bonuses');
const [pts, reloadPts] = useApi('/api/points');
const [lead] = useApi('/api/leaders?limit=1&prev=0');
const [tab, setTab] = React.useState(params.tab === 'mine' ? 'mine' : params.tab === 'history' ? 'history' : 'shop');
const [claim, modal] = useClaim(() => { reload(); reloadPts(); setTab('mine'); });
const total = usePointsTotal();
React.useEffect(() => { if (pts && pts.total !== getPoints()) setPoints(pts.total); }, [pts]);
React.useEffect(() => {
if (params.tab === 'rules') setTimeout(() => { const el = document.getElementById('rules'); el && el.scrollIntoView(); }, 80);
}, [data]);
const frame = (body, rail) => (
);
if (data === undefined) return frame(Загружаем витрину…
);
if (!data) return frame(Витрина не загрузилась. Обнови страницу через минуту.
);
const me = lead && lead.me;
const showHow = (id) => { setTab('mine'); setTimeout(() => { const el = document.getElementById('mine-' + id); el && el.scrollIntoView({ block: 'center' }); }, 60); };
return frame(<>
Баллы и подарки
У тебя {fmtNum(total)} {ballov(total)}
Получай баллы за активности в сообществе и забирай подарки: подарок открывается, когда набираешь его порог,
а баллы при этом не списываются. Доступно сейчас: {data.available} из {data.count} .
{me && me.place && !me.team && (
nav('leaders')}>{Icons.trophy()} {me.place} место в {MONTH_PREP[monthIdx(lead.period)]}
)}
820 || params.tab === 'rules'}>
{Icons.info()} Как работают баллы За что начисляем и сколько
{[['shop', 'Подарки'], ['mine', `Мои${data.mine.length ? ' · ' + data.mine.length : ''}`], ['history', 'История']].map(([k, t]) => (
setTab(k)}>{t}
))}
Подарки за баллы
Сначала доступные · {data.note ? data.note.toLowerCase() : 'подарки обновляем в начале месяца'}
{data.items.length
? {data.items.map(b => showHow(b.id)}/>)}
: Подарков пока нет. Они появятся в начале месяца.
}
Мои подарки {data.mine.length > 0 && {data.mine.length} }
{modal}
>);
}
/* ---- экран «Лидеры» ------------------------------------------------------ */
function MePlaque({ data, period }) {
const me = data.me || {};
const mon = MONTH_PREP[monthIdx(data.period)];
const scope = period === 'all' ? 'за всё время' : `в ${mon}`;
if (me.team) return (
—
Команда в рейтинг не входит
Основатели и модераторы видят рейтинг, но в нём не участвуют.
);
if (!me.place) return (
—
Пока без места
Набери первые баллы, и появишься в рейтинге. Эфир сразу даёт +{(CFG().points || {}).attend || 50}.
);
if (me.hidden) return (
{me.place}
{Icons.eyeoff()} Ты скрыт из рейтинга
Другие тебя не видят. Место и баллы видны только тебе, баллы копятся как обычно.
);
const above = me.place > 1 ? me.points + me.need - 1 : me.points;
return (
);
}
function Leaders({ nav, params = {} }) {
window.__NAV = nav;
const [period, setPeriod] = React.useState(params.period === 'all' ? 'all' : 'month');
const [d, reloadLeaders] = useApi('/api/leaders?period=' + period);
const [pts, reloadPts] = useApi('/api/points');
const [ev] = useApi('/api/events');
const [hidden, setHidden] = React.useState(null);
React.useEffect(() => { if (pts) setHidden(pts.rating_hidden); }, [pts]);
const toggle = async (v) => {
setHidden(v);
const r = await putJSON('/api/prefs', { rating_hidden: v });
if (!r.ok) setHidden(!v); else { reloadLeaders(); reloadPts(); }
};
const mon = d ? MONTH_GEN[monthIdx(d.period === 'all' ? curPeriod() : d.period)] : '';
const nextEv = ev && ev.events && ev.events.find(e => e.access !== 'pro' || accessTier() === 'pro');
return (
Лидеры
{period === 'all' ? 'Лидеры за всё время' : `Лидеры ${mon}`}
{period === 'all' ? 'Баллы за последние 12 месяцев: начисленные раньше сгорают.' : 'Рейтинг месяца обнуляется 1-го числа.'} Основатели и команда в рейтинг не входят.
{[['month', 'Месяц'], ['all', 'За всё время']].map(([k, t]) => (
setPeriod(k)}>{t}
))}
{d === undefined &&
Считаем рейтинг…
}
{d === null &&
Рейтинг не загрузился. Обнови страницу через минуту.
}
{d &&
}
{d && (d.top.length ? (
Место Участник Сфера Баллы
{d.top.map(r =>
)}
{d.around.length > 0 && <>
· · ·
{d.around.map(r =>
)}>}
) : (
{period === 'all' ? 'Рейтинг пока пустой' : 'Месяц только начался'}
Первые баллы за материалы, эфиры и кейсы появятся здесь. {d.prev && d.prev.top.length ? 'Лидеры прошлого месяца справа.' : ''}
))}
{d && !(d.me && d.me.team) && (
)}
{d && d.prev && (
Прошлый месяц
{d.prev.top.length ? d.prev.top.map(r =>
) : В {MONTH_PREP[monthIdx(d.prev.period)]} рейтинга ещё не было.
}
{d.prev.me && d.prev.me.place && Ты в {MONTH_PREP[monthIdx(d.prev.period)]}: {d.prev.me.place} место
}
)}
);
}
Object.assign(window, { useApi, putJSON, refreshMe, MONTH_NOM, MONTH_GEN, MONTH_PREP, monthIdx, curPeriod, ballov, fmtUntil, fmtShortDay,
PointsChips, useClaim, BonusCard, BonusMini, TopMini, LeaderRow, meLine, Bar, MyBonuses, History, PointsRules, PointsNote, Bonuses, Leaders });