// 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 (
{c.type !== 'prompt' && }
{c.type === 'video' &&
{Icons.play()} }
);
}
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 }); }}>
{Icons.book()} База знаний
Кейс от основателей {it.locked && }
{it.title}
{it.summary}
{who.length ? who.join(', ') : 'Команда hakku'}
);
}
const pubCaseBtn = (nav, label = 'Опубликовать кейс', cls = 'os-btn') => (
nav('case_new')}>{Icons.plus()} {label}
);
/* ---- лента ------------------------------------------------------------------ */
function Select({ label, value, onChange, options }) {
return (
onChange(e.target.value || null)} aria-label={label}>
{label}
{options.map(o => {o.title} )}
);
}
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(', ')}
Открыть →
)}
set('type', null)}>Все
{caseTypes().map(t => set('type', f.type === t.id ? null : t.id)}>{t.title} )}
set('block', v)} options={(CFG().clusters || []).map(c => ({ id: c.id, title: c.title }))}/>
set('sphere', v)} options={(CFG().spheres || []).filter(s => s.id !== 'other')}/>
set('goal', v)} options={CFG().goals || []}/>
{data && {n} {plural(n, 'кейс', 'кейса', 'кейсов')} · сначала новые }
{data === undefined &&
Загружаем кейсы…
}
{data === null &&
Кейсы не загрузились. Обнови страницу через минуту.
}
{data && !n && any && (
По этим фильтрам пока пусто
Кейсов с такими условиями ещё нет. Сними один фильтр или стань первым автором.
{ setF({ type: null, block: null, sphere: null, goal: null }); setPage(1); }}>Сбросить фильтры
{pubCaseBtn(nav, 'Опубликовать кейс', 'os-btn sm')}
)}
{data && !n && !any && (
Здесь будут кейсы участников
Продукт, промпт, история или видеоразбор: что ты сделал с ИИ и что вышло. Шаблон займёт 10 минут, за кейс +{pts} баллов после проверки.
{pubCaseBtn(nav, 'Опубликовать первый кейс', 'os-btn sm')}
)}
{n > 0 && (
<>
{shown.map(c => )}
{shown.length < n &&
setPage(p => p + 1)}>Показать ещё
}
>
)}
{kbList.length > 0 && (
)}
);
}
/* ---- страница кейса ----------------------------------------------------------- */
function PromptBlock({ text }) {
return (
track('case:copy')}/>
{text}
);
}
function Steps({ text }) {
const lines = String(text || '').split('\n').map(s => s.trim()).filter(Boolean);
const numbered = lines.length > 1 && lines.every(l => /^\d+[.)]\s/.test(l));
if (numbered) return {lines.map((l, i) => {l.replace(/^\d+[.)]\s*/, '')} )} ;
return {text}
;
}
function StatusNote({ c, nav }) {
if (!c.status || c.status === 'published' && !c.mine) return null;
const s = CASE_STATUS[c.status];
const site = c.consent === 'no' ? 'только в сообществе' : c.on_site ? 'на сайте hakku.ai' : 'на сайт после флажка команды';
return (
{s[0]}
{c.status === 'published' && Виден участникам · {site} }
{c.status === 'pending' && Команда посмотрит в течение пары дней. Баллы придут после проверки. }
{c.team_comment &&
Комментарий команды: {c.team_comment}
}
{c.mine && ['pending', 'returned'].includes(c.status) && (
nav('case_new', { edit: c.id })}>{c.status === 'returned' ? 'Исправить и отправить' : 'Изменить'}
)}
);
}
function CaseBody({ c, nav, preview = false }) {
return (
<>
{c.block && {clusterTitle(c.block)} }
{c.sphere && {sphereTitleOf(c.sphere)} }
{c.goal && Цель: {goalTitleById(c.goal)} }
{c.title || 'Название кейса'}
{c.author.name} {[c.author.sphere, c.published_at || c.created_at ? fmtUntil(c.published_at || c.created_at) : ''].filter(Boolean).join(' · ')}
{c.cover_url && }
Инструменты
{(c.tools || []).map(t => {t} )}
{c.prompt &&
}
{c.link && (
{c.type === 'video' ? 'Видеоразбор' : 'Ссылка'}
)}
>
);
}
function CasePage({ nav, params = {} }) {
const [data] = useApi('/api/cases/' + encodeURIComponent(params.id || ''));
const c = data && data.case;
return (
{c && (
Автор
{c.author.name} {[c.author.sphere, c.author.city].filter(Boolean).join(' · ')}
{data.author_cases} {plural(data.author_cases, 'кейс', 'кейса', 'кейсов')} в сообществе
{data.similar.length > 0 && (
Похожие кейсы
{data.similar.slice(0, 1).map(x =>
)}
)}
{data.related.length > 0 && (
)}
)}
);
}
/* ---- форма «Опубликовать кейс» --------------------------------------------------- */
const ERR_CASE = {
title_required: 'Добавь название.', title_too_long: 'Название длиннее 60 знаков.', bad_type: 'Выбери тип кейса.',
task_required: 'Опиши задачу: что было не так до ИИ.', ai_how_required: 'Расскажи по шагам, как помог ИИ.',
result_required: 'Напиши результат, лучше в цифрах.', tools_required: 'Добавь хотя бы один инструмент.',
task_too_long: 'Задача длиннее 500 знаков.', ai_how_too_long: 'Шаги длиннее 500 знаков.', result_too_long: 'Результат длиннее 500 знаков.',
prompt_too_long: 'Промпт длиннее 500 знаков.', bad_link: 'Ссылка должна начинаться с https://', video_link_required: 'Для видеоразбора нужна ссылка на запись.',
consent_required: 'Выбери, показывать ли кейс на сайте.', bad_cover: 'Обложка не загрузилась, выбери файл ещё раз.',
too_many_tools: 'Не больше 10 инструментов.', tool_too_long: 'Название инструмента длиннее 30 знаков.', too_many: 'Сегодня уже много кейсов, продолжим завтра.',
not_editable: 'Этот кейс уже проверили, изменить нельзя.', server: 'Сервер не ответил, попробуй ещё раз.',
};
const ERR_FIELD = { title_required: 'title', title_too_long: 'title', bad_type: 'type', task_required: 'task', task_too_long: 'task',
ai_how_required: 'ai_how', ai_how_too_long: 'ai_how', result_required: 'result', result_too_long: 'result', tools_required: 'tools',
too_many_tools: 'tools', tool_too_long: 'tools', prompt_too_long: 'prompt', bad_link: 'link', video_link_required: 'link', consent_required: 'consent', bad_cover: 'cover' };
const DRAFT_KEY = 'cm_case_draft';
const emptyCase = () => {
const p = ME().profile || {};
return { title: '', type: '', cover: null, cover_url: null, task: '', tools: [], ai_how: '', result: '', prompt: '', link: '', mode: 'prompt',
block: null, sphere: p.sphere && p.sphere !== 'other' ? p.sphere : null, goal: p.goal && goalOf(p.goal) ? goalOf(p.goal).id : null, consent: '' };
};
function Counter({ v, max }) {
const n = String(v || '').length;
return max ? ' over' : n > max * .9 ? ' near' : ''}`}>{n} / {max} ;
}
function Field({ label, req, hint, opt, children, count, err, id }) {
return (
{label}{req && * }{opt && {opt} }
{hint &&
{hint}
}
{children}
{(count || err) &&
{err || ''} {count}
}
);
}
function ToolsInput({ value, onChange }) {
const [t, setT] = React.useState('');
const sugg = (casesCfg().tools || []).filter(x => !value.some(v => v.toLowerCase() === x.toLowerCase()));
const add = (x) => { const s = String(x || '').trim().slice(0, 30); if (s && !value.some(v => v.toLowerCase() === s.toLowerCase()) && value.length < 10) onChange([...value, s]); setT(''); };
return (
{value.map(v => {v} onChange(value.filter(x => x !== v))}>× )}
{ 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) => (
);
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 => (
set('type', t.id)}/>
{t.title} {t.d}
))}
e.preventDefault()} onDrop={e => { e.preventDefault(); upload(e.dataTransfer.files[0]); }}>
upload(e.target.files[0])}/>
{v.cover_url ? : {Icons.image()} }
{v.cover_url ? 'Обложка загружена' : 'Скриншот или картинка'}
{up === 'Загружаем…' ? 'Загружаем…' : v.cover_url ? 'Нажми, чтобы заменить' : 'Перетащи сюда или выбери файл · JPG, PNG, WebP до 3 МБ'}
{v.cover_url && { e.preventDefault(); setV(o => ({ ...o, cover: null, cover_url: null })); }}>Убрать }
}>
set('tools', x)}/>
}>
}>
{isVideo ? (
set('link', e.target.value)} placeholder="https://"/>
) : (
: null}>
{[['prompt', 'Промпт'], ['link', 'Ссылка']].map(([k, t]) => set('mode', k)}>{t} )}
{v.mode === 'prompt'
?
)}
set('block', x)} options={(CFG().clusters || []).map(c => ({ id: c.id, title: c.title }))}/>
set('sphere', x)} options={(CFG().spheres || []).filter(s => s.id !== 'other')}/>
set('goal', x)} options={CFG().goals || []}/>
{(casesCfg().consent || []).map(o => (
set('consent', o.id)}/>
{o.title} {o.d}
))}
{editId ? 'После правки кейс снова уйдёт на проверку' : saved ? <>{Icons.check()} Черновик сохранён> : 'Черновик сохраняется в браузере'}
{ const el = document.getElementById('cm-preview'); el && el.scrollIntoView({ behavior: 'smooth' }); }}>Предпросмотр
{busy ? 'Отправляем…' : 'Отправить на проверку'}
{err && !ERR_FIELD[err] &&
{ERR_CASE[err] || ERR_CASE.server}
}
Так кейс увидят другие
{}}/>
Карточка в ленте «Кейсы» и в блоке «Новое от участников» на главной. Появится после проверки командой.
{lim != null && (
{Icons.trophy()} В этом месяце {used == null ? '…' : used} из {lim} кейсов с баллами
{used != null && used >= lim && Лимит месяца выбран: кейс опубликуем, но без баллов.
}
)}
>);
}
/* ---- блоки для главной, материала и профиля ----------------------------------------- */
function PublishRow({ nav }) {
const pts = (CFG().points || {}).case || 100;
return (
{Icons.plus()}
Сделал что-то с ИИ? Опубликуй кейс по шаблону. За полный кейс +{pts} баллов после проверки.
{pubCaseBtn(nav, 'Опубликовать кейс', 'os-btn sm')}
);
}
function NewFromMembers({ data, nav }) {
if (data === undefined) return null;
const list = (data && data.cases) || [];
const pts = (CFG().points || {}).case || 100;
return (
{list.length ? (
<>
{list.slice(0, 3).map(c => )}
nav('case_new')}>{Icons.plus()} Опубликовать кейс · +{pts}
>
) : (
Здесь появятся кейсы участников
Кейс, промпт или история о том, как ИИ помог в работе. Первым авторам по {pts} баллов после проверки.
{pubCaseBtn(nav, 'Опубликовать первый кейс', 'os-btn sm')}
)}
);
}
function CasesByTopic({ block, nav }) {
const [data] = useApi(block && isMember() ? `/api/cases?block=${encodeURIComponent(block)}&limit=3&kb=0` : null);
if (!block || !isMember() || !data) return null;
return (
{data.cases.length
?
{data.cases.map(c => )}
:
}
);
}
function MyCases({ nav }) {
const [data] = useApi('/api/cases?mine=1');
if (!data) return null;
return (
);
}
Object.assign(window, { CASE_TYPE, CASE_STATUS, TypeBadge, CaseCover, CaseCard, KbCaseCard, CasesFeed, CasePage, CaseForm, CaseBody,
NewFromMembers, PublishRow, CasesByTopic, MyCases, Select });