// month.jsx — неделя 3, блоки главной и профиля: // MonthSummary — «Итоги месяца» (CJM 3.1): за прошлый календарный месяц по МСК — материалы, эфиры, // записи, кейсы, баллы и место в рейтинге месяца. На главной — первые 7 дней нового // месяца, карточку можно скрыть; в профиле — всегда, с листанием месяцев. // Правка 05.10.2026: пока у участника нет ни одного прошлого месяца с баллами, // на главной вместо итогов — велкам (текст из «Команда» → «Тексты»), скрывается крестиком. // People — «С кем пообщаться» (онбординг 2.5): до 3 участников по цели, сфере, городу; // «Показать других» — следующая тройка. В профиле — переключатель показа себя. // Считает и подбирает сервер (/api/summary, /api/people), здесь только рисуем. const sumNoun = { materials: ['материал', 'материала', 'материалов'], events: ['эфир', 'эфира', 'эфиров'], recordings: ['запись', 'записи', 'записей'], cases: ['кейс', 'кейса', 'кейсов'] }; // глагол согласуем с числом: 1 материал дочитан, 2 материала дочитано const SUM_TILES = [['materials', 'дочитан', 'дочитано'], ['events', 'посещён', 'посещено'], ['recordings', 'просмотрена', 'просмотрено'], ['cases', 'опубликован', 'опубликовано']]; const verbOf = (n, one, many) => (Math.abs(n) % 10 === 1 && Math.abs(n) % 100 !== 11 ? one : many); function SummaryBody({ s, nav }) { const mon = MONTH_PREP[monthIdx(s.period)]; if (s.empty) return (

В {mon} ты только присматривался к сообществу, и это нормально. Начни с одного материала по цели или приходи на ближайший эфир: в следующих итогах будет что посчитать.

); const place = s.team ? 'Команда вне рейтинга' : s.place ? <>{s.place} место из {s.ranked} в рейтинге {MONTH_GEN[monthIdx(s.period)]} : null; return ( <>
{fmtNum(s.stats.points)} {ballov(s.stats.points)} за {MONTH_NOM[monthIdx(s.period)]} {place && }
{SUM_TILES.map(([k, one, many]) => (
{s.stats[k]} {plural(s.stats[k], ...sumNoun[k])} {verbOf(s.stats[k], one, many)}
))}
{s.stats.hosted > 0 &&

И ещё {s.stats.hosted} {plural(s.stats.hosted, 'эфир', 'эфира', 'эфиров')} ты провёл сам. Спасибо!

} ); } // Велкам новичку: материал по цели (первый недочитанный из рекомендаций) и ближайший эфир function WelcomeCard({ w, nav, rec, onHide }) { const next = (rec || []).find(r => !r.locked && !r.done) || (rec || []).find(r => !r.locked) || null; const ev = w.event; return (

{w.title}

{w.text}

); } // home — карточка на главной (сама решает, показываться ли); иначе — блок профиля с листанием function MonthSummary({ nav, home = false, rec = null }) { const [period, setPeriod] = React.useState(null); const [s] = useApi('/api/summary' + (period ? '?period=' + period : '')); const [gone, setGone] = React.useState(false); if (!s || gone) return null; if (home && s.welcome) return { setGone(true); postJSON('/api/summary/welcome/hide'); }}/>; if (home && !s.home) return null; const mon = MONTH_NOM[monthIdx(s.period)]; const hide = () => { setGone(true); postJSON('/api/summary/hide', { period: s.period }); toast('Итоги скрыты. Они всегда есть в профиле', { label: 'Профиль', onClick: () => nav('profile') }); }; const i = s.periods.indexOf(s.period); const prev = i > 0 ? s.periods[i - 1] : null, next = i >= 0 && i < s.periods.length - 1 ? s.periods[i + 1] : null; if (home) return (
Итоги месяца · {mon}
); return (

Итоги месяца

{mon} {s.period.slice(0, 4)}
); } /* ---- «С кем пообщаться» ------------------------------------------------- */ const MATCH_LABEL = { goal: 'та же цель', sphere: 'та же сфера', city: 'тот же город' }; function PersonCard({ p }) { return (
{p.name}
{[p.sphere, p.city].filter(Boolean).join(' · ')}
{p.match.map(k => {MATCH_LABEL[k]})}
{p.goal &&
Цель: {p.goal}
} {p.about ?

{p.about}

:

Пока ничего не рассказал о себе.

} {p.telegram_url ? track('people:tg')}>{Icons.send()} Написать в Telegram : Telegram не указан. Найди в общем чате сообщества.}
); } function People({ nav, profile = false }) { const [page, setPage] = React.useState(0); const [d, reload] = useApi('/api/people?page=' + page); const [vis, setVis] = React.useState(null); React.useEffect(() => { if (d) setVis(d.visible); }, [d]); if (!d) return null; if (!profile && (!d.has_profile || !d.people.length)) return null; const toggle = async (v) => { setVis(v); const r = await putJSON('/api/prefs/match', { visible: v }); if (!r.ok) setVis(!v); else toast(v ? 'Тебя снова видно в рекомендациях' : 'Тебя больше не показываем в рекомендациях'); }; const more = d.pages > 1 && ( ); const grid = d.people.length ?
{d.people.map(p => )}
:
{d.has_profile ? 'Пока не нашли участников с похожей целью, сферой или городом. Загляни через неделю.' : 'Заполни анкету: по цели, сфере и городу подберём, с кем пообщаться.'}
; if (!profile) return (

С кем пообщаться

{more}

Участники с похожей целью, сферой или из твоего города. Подборка обновляется по понедельникам.

{grid}
); return (

С кем пообщаться

{more}
{grid}
); } Object.assign(window, { MonthSummary, People });