// 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 (
{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()}>

Забрать подарок?

{b.title}

{b.gives}{b.type === 'pro_week' ? ' Неделя начнётся сразу.' : b.type === 'manual' ? ' Команда свяжется с тобой сама.' : ''}

Баллов останется{fmtNum(total)}

Баллы не тратятся: подарок открылся, потому что ты набрал {fmtNum(b.threshold)}. Место в рейтинге не изменится.

{err &&
{err}
}
); } 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' && } {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' && }
))}
{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 && }
); 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) => (
{body}
); 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 && ( )}
820 || params.tab === 'rules'}> {Icons.info()}Как работают баллыЗа что начисляем и сколько
{[['shop', 'Подарки'], ['mine', `Мои${data.mine.length ? ' · ' + data.mine.length : ''}`], ['history', 'История']].map(([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 (
{me.place}
Ты на {me.place} месте · {fmtNum(me.points)} {ballov(me.points)} {scope}
{meLine(me)}
{me.place > 1 && (
{fmtNum(me.points)} / {fmtNum(above)}
)} { e.preventDefault(); window.__NAV('bonuses', { tab: 'rules' }); }}>Как набрать →
); } 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]) => ( ))}
{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 });