// cases.jsx — кейсы участников (CJM Насти, п. 3.7; решения Саши 02.10.2026). // «Кейсы» в меню — один раздел: кейсы участников из базы данных + материалы кластера // «Кейсы» из базы знаний (карточками рядом, с пометкой «от основателей»). // CasesFeed — лента с фильтрами (тип чипами; блок, сфера, цель списками) // CasePage — страница кейса (?view=case&id=) // CaseForm — «Опубликовать кейс» по шаблону, превью, черновик в браузере (?view=case_new[&edit=id]) // NewFromMembers — блок главной; CasesByTopic — «Кейсы по теме» в материале; MyCases — профиль // Баллы за кейс — после проверки командой. Витрины на hakku.ai пока нет (решение отложено), // но согласие автора хранится и уходит в GET /api/public/cases. // TODO(telegram): анонс нового кейса в чат сообщества — когда будет токен бота. const CASE_TYPE = { product: ['Продукт', 'blue'], prompt: ['Промпт', 'mag'], story: ['История', 'orange'], video: ['Видеоразбор', 'ink2'] }; const CASE_STATUS = { pending: ['На проверке', 'orange'], published: ['Опубликован', 'green'], returned: ['Вернули с комментарием', 'mag'], hidden: ['Скрыт командой', ''] }; const casesCfg = () => CFG().cases || {}; const caseTypes = () => casesCfg().types || Object.entries(CASE_TYPE).map(([id, [title]]) => ({ id, title })); const sphereTitleOf = (id) => ((CFG().spheres || []).find(s => s.id === id) || {}).title || ''; const goalTitleById = (id) => ((CFG().goals || []).find(g => g.id === id) || {}).title || ''; const AUTHORS_KB = { alex: 'Александр Д.', nikolai: 'Николай П.', sergei: 'Сергей Е.', nastya: 'Настя' }; function TypeBadge({ type }) { const t = CASE_TYPE[type] || ['Кейс', '']; return {t[0]}; } function CaseCover({ c, big = false }) { if (c.cover_url) return (
{c.type === 'video' && {Icons.play()}}
); return ( ); } function CaseCard({ c, nav, compact = false }) { const tools = c.tools || []; return ( { e.preventDefault(); nav('case', { id: c.id }); }}>
{c.block && {clusterTitle(c.block)}}
{c.title}
Результат: {c.result}
{!compact && tools.length > 0 && (
{tools.slice(0, 3).map(t => {t})}{tools.length > 3 && +{tools.length - 3}}
)}
{[c.author.name, c.author.sphere].filter(Boolean).join(' · ')}
); } // материал кластера «Кейсы» из базы знаний — в той же ленте function KbCaseCard({ it, nav }) { const who = (it.authors || []).map(a => AUTHORS_KB[a]).filter(Boolean); return ( { e.preventDefault(); nav('kb', { m: it.slug }); }}>
Кейсот основателей{it.locked && }
{it.title}
{it.summary}
{who.length ? who.join(', ') : 'Команда hakku'}
); } const pubCaseBtn = (nav, label = 'Опубликовать кейс', cls = 'os-btn') => ( ); /* ---- лента ------------------------------------------------------------------ */ function Select({ label, value, onChange, options }) { return ( ); } function CasesFeed({ nav, params = {} }) { const [f, setF] = React.useState({ type: params.type || null, block: params.c || null, sphere: null, goal: null }); const [page, setPage] = React.useState(1); const q = new URLSearchParams(Object.entries(f).filter(([, v]) => v)).toString(); const [data] = useApi('/api/cases' + (q ? '?' + q : '')); const [mine] = useApi('/api/cases?mine=1'); const set = (k, v) => { setF(x => ({ ...x, [k]: v })); setPage(1); }; const any = Object.values(f).some(Boolean); const list = data ? data.cases : []; const kbList = data ? data.kb : []; const shown = list.slice(0, page * 9); const pending = mine ? mine.cases.filter(c => c.status === 'pending').length : 0; const returned = mine ? mine.cases.filter(c => c.status === 'returned').length : 0; const pts = (CFG().points || {}).case || 100; const n = list.length; return (
Кейсы

ИИ в деле: кейсы основателей и участников

Продукты, промпты, истории и видеоразборы по одному шаблону: задача, инструменты и результат. Рядом кейсы основателей из базы знаний.

{pubCaseBtn(nav)}
{(pending > 0 || returned > 0) && ( { e.preventDefault(); nav('profile', { s: 'cases' }); }}> Мои кейсы: {[pending && `${pending} на проверке`, returned && `${returned} вернули с комментарием`].filter(Boolean).join(', ')} Открыть → )}
{caseTypes().map(t => )}
set('sphere', v)} options={(CFG().spheres || []).filter(s => s.id !== 'other')}/> { const x = e.target.value; if (sugg.includes(x)) add(x); else setT(x); }} onKeyDown={e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); add(t); } if (e.key === 'Backspace' && !t && value.length) onChange(value.slice(0, -1)); }} onBlur={() => t && add(t)} aria-label="Инструменты"/> {sugg.map(x =>
); } function CaseForm({ nav, params = {} }) { const editId = params.edit || null; const [v, setV] = React.useState(() => { if (editId) return null; try { const d = JSON.parse(localStorage.getItem(DRAFT_KEY) || 'null'); if (d && typeof d === 'object') return { ...emptyCase(), ...d }; } catch (e) {} return emptyCase(); }); const [saved, setSaved] = React.useState(false); const [err, setErr] = React.useState(null); const [busy, setBusy] = React.useState(false); const [up, setUp] = React.useState(''); const [pts] = useApi('/api/points'); React.useEffect(() => { if (!editId) return; fetch('/api/cases/' + encodeURIComponent(editId), { credentials: 'same-origin' }).then(r => r.json()).then(j => { const c = j && j.ok && j.case; if (!c || !c.mine) return setV(emptyCase()); setV({ ...emptyCase(), ...c, prompt: c.prompt || '', link: c.link || '', mode: c.link && !c.prompt ? 'link' : 'prompt' }); }).catch(() => setV(emptyCase())); }, [editId]); React.useEffect(() => { if (!v || editId) return; const t = setTimeout(() => { try { localStorage.setItem(DRAFT_KEY, JSON.stringify(v)); setSaved(true); } catch (e) {} }, 600); return () => clearTimeout(t); }, [v]); const frame = (body, rail) => (
{body}
); if (!v) return frame(
Открываем кейс…
); const set = (k, x) => { setV(o => ({ ...o, [k]: x })); setSaved(false); if (err && ERR_FIELD[err] === k) setErr(null); }; const ferr = (k) => err && ERR_FIELD[err] === k ? ERR_CASE[err] : null; const pointsCase = (CFG().points || {}).case || 100; const upload = async (file) => { if (!file) return; if (!/^image\/(jpeg|png|webp)$/.test(file.type)) return setUp('Подойдёт JPG, PNG или WebP.'); if (file.size > 3 * 1024 * 1024) return setUp('Файл больше 3 МБ. Сожми картинку или сделай скрин меньше.'); setUp('Загружаем…'); try { const r = await fetch('/api/cases/cover', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': file.type }, body: file }); const j = await r.json().catch(() => null); if (!r.ok || !j || !j.ok) return setUp(j && j.error === 'too_big' ? 'Файл больше 3 МБ.' : 'Не получилось загрузить, попробуй другой файл.'); setV(o => ({ ...o, cover: j.cover, cover_url: j.url })); setUp(''); } catch (e) { setUp('Не получилось загрузить, попробуй ещё раз.'); } }; const body = () => ({ title: v.title, type: v.type, cover: v.cover, task: v.task, tools: v.tools, ai_how: v.ai_how, result: v.result, prompt: v.mode === 'prompt' || v.type === 'video' ? (v.type === 'video' ? '' : v.prompt) : '', link: v.mode === 'link' || v.type === 'video' ? v.link : '', block: v.block, sphere: v.sphere, goal: v.goal, consent: v.consent }); const submit = async () => { setBusy(true); setErr(null); const b = body(); const r = await (editId ? putJSON('/api/cases/' + encodeURIComponent(editId), b) : postJSON('/api/cases', b)); setBusy(false); if (!r.ok) { const e = (r.j && r.j.error) || 'server'; setErr(e); const el = ERR_FIELD[e] && document.getElementById('f-' + ERR_FIELD[e]); if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' }); return; } if (!editId) { try { localStorage.removeItem(DRAFT_KEY); } catch (e) {} } toast('Кейс отправлен на проверку. Баллы придут после неё.'); nav('case', { id: r.j.case.id }); }; const me = ME(); const prof = me.profile || {}; const myName = prof.first_name ? `${prof.first_name}${prof.last_name ? ' ' + prof.last_name[0] + '.' : ''}` : (me.user.name || 'Ты'); const preview = { id: 'preview', title: v.title || 'Название кейса', type: v.type || 'product', result: v.result || 'Результат появится здесь', tools: v.tools, block: v.block, cover_url: v.cover_url, author: { name: myName, initials: null, sphere: sphereTitleOf(v.sphere) || '' } }; // лимит кейсов с баллами в месяц: null — без лимита (правка 05.10.2026), карточку лимита не рисуем const used = pts ? pts.case_limit.used : null, lim = pts ? pts.case_limit.limit : null; const isVideo = v.type === 'video'; return frame(<> { e.preventDefault(); nav('cases'); }}>{Icons.back()} Кейсы

{editId ? 'Правка кейса' : 'Опубликовать кейс'}

Один шаблон для всех: задача, инструменты, как помог ИИ, результат. За полный кейс +{pointsCase} баллов после проверки командой.

{v.team_comment && v.status === 'returned' &&
Комментарий команды: {v.team_comment}
}
}> set('title', e.target.value)} placeholder="Например: ассистент для еженедельного отчёта"/>
{caseTypes().map(t => ( ))}
}>