// chrome.jsx — общие примитивы LMS сообщества: бренд, иконки, сессия, данные базы, // Sidebar, Topbar, замок, карточка тарифа. Всё экспортируется в window. // Бренд нейтральный hakku (ДС Hakku 2.0), без клиентских локапов. // // Права решает сервер: /api/auth/me отдаёт состояние участника, /api/kb — материалы // с флагом locked. Здесь только рисуем. const HakkuGlyph = ({ size = 24, color = 'var(--ink)' }) => ( ); const Wordmark = () => хакку.ии; const CoBrand = ({ size = 20 }) => (
); /* ---- icons --------------------------------------------------- */ const I = (p) => ; const Icons = { home: (c)=>, book: (c)=>, user: (c)=>, users: (c)=>, search: (c)=>, cal: (c)=>, lock: (c)=>, spark: (c)=>, flag: (c)=>, chev: (c)=>, back: (c)=>, check: (c)=>, ext: (c)=>, }; /* ---- сессия и конфиг ----------------------------------------- */ // window.__ME — ответ /api/auth/me: { user|null, member:{state,tier,…}, profile|null } const ME = () => window.__ME || { user: null, member: { state: 'anon', tier: 'none' }, profile: null }; const CFG = () => window.__CFG || { goals: [], levels: [], roles: [], share: [], clusters: [], pricing_url: 'https://hakku.ai/community#pricing', quiz_url: 'https://hakku.ai/community/quiz' }; const isAnon = () => !ME().user; const isMember = () => ME().member && ME().member.state === 'active'; const TIER_LABEL = { pro: 'PRO', enthusiast: 'Enthusiast', kb_only: 'Только База знаний', none: 'Без подписки' }; const STATUS_LABEL = { active: 'активна', trial: 'пробный период', paused: 'на паузе', cancelled: 'отменена' }; const fmtDate = (iso) => iso ? new Date(iso + 'T00:00:00').toLocaleDateString('ru-RU', { day:'numeric', month:'long', year:'numeric' }) : '—'; const fmtDay = (iso) => new Date(iso + 'T00:00:00').toLocaleDateString('ru-RU', { day:'numeric', month:'long' }); const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || ''; // База знаний: список карточек без тел (тела — только /api/kb/:slug под проверкой) window.KB_READY = fetch('/api/kb', { credentials:'same-origin' }) .then(r => r.json()).then(j => (j && j.ok) ? j : { clusters: [], items: [] }) .catch(() => ({ clusters: [], items: [] })); const useKB = () => { const [kb, setKb] = React.useState(null); React.useEffect(()=>{ window.KB_READY.then(setKb); }, []); return kb; }; const clusterTitle = (id) => ((CFG().clusters || []).find(c => c.id === id) || {}).title || id; const TYPE_LABEL = { guide:'Гайд', howto:'Инструкция', case:'Кейс', prompt:'Промпт', recording:'Запись', checklist:'Чек-лист', atlas:'Подборка', essay:'Статья' }; /* ---- вход и выход ---------------------------------------------- */ // Вход — только Hakku ID (auth.hakku.ai). Better Auth отдаёт URL авторизации, // после входа auth.hakku.ai возвращает на /api/auth/oauth2/callback/hakku → callbackURL. async function ssoLogin(back) { const cb = back || (location.pathname + location.search) || '/os/'; try { const r = await fetch('/api/auth/sign-in/oauth2', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({ providerId:'hakku', callbackURL: cb, errorCallbackURL: '/os/?sso_error=1' }), }); const j = await r.json().catch(()=>null); if (j && j.url) { location.href = j.url; return true; } } catch (e) {} return false; } function osLogout() { fetch('/api/auth/logout', { method:'POST', credentials:'same-origin' }) .catch(()=>{}).then(()=>{ location.href = '/os/'; }); } // Кнопка входа с понятной ошибкой (в dev-моке SSO нет — переключайте роль /__role/*) function LoginButton({ label = 'Войти через Hakku ID', className = 'os-btn', style }) { const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(''); const go = async () => { if (busy) return; setBusy(true); setErr(''); const ok = await ssoLogin(); if (!ok) { setBusy(false); setErr('Не получилось открыть вход. Попробуйте ещё раз через минуту.'); } }; return ( <> {err &&
{err}
} ); } /* ---- замок и тариф --------------------------------------------- */ const LOCK_TEXT = { pro: { h: 'Только для PRO', d: 'Материал из программы PRO: для тех, кто внедряет ИИ в бизнес или команду либо делает свой ИИ-продукт.' }, members: { h: 'Для участников сообщества', d: 'Полный текст открыт участникам сообщества hakku с тарифом Enthusiast или PRO.' }, }; function LockCard({ lock = 'members', compact = false }) { const t = LOCK_TEXT[lock] || LOCK_TEXT.members; const anon = isAnon(); const cta = lock === 'pro' && ME().member.state === 'active' ? 'Перейти в PRO' : 'Выбрать тариф'; return (
{Icons.lock()}
{t.h}
{!compact &&

{t.d}

}
{cta} ↗ {anon && }
); } const LockBadge = ({ lock }) => ( {Icons.lock()} {lock === 'pro' ? 'PRO' : 'Участникам'} ); // Карточка участника: тариф, статус, срок. Используется в правой колонке и в профиле. function MemberCard() { const { user, member } = ME(); if (!user) return null; const active = member.state === 'active'; return (
Ваш тариф
{active ? TIER_LABEL[member.tier] || member.tier : 'Нет доступа'}
{member.admin ? 'Команда сообщества — доступ ко всем материалам.' : active ? <>Оплачено до {fmtDate(member.paid_until)}{member.status === 'trial' ? ' · пробный период' : ''} : member.state === 'expired' ? `Подписка ${STATUS_LABEL[member.status] || 'закончилась'}${member.paid_until ? ' · до ' + fmtDate(member.paid_until) : ''}` : 'Почта не найдена среди участников.'}
{(member.tier === 'enthusiast' || member.tier === 'kb_only' || !active) && !member.admin && ( {active ? 'Перейти в PRO ↗' : 'Выбрать тариф ↗'} )}
); } function MemberRail({ children }) { return ( ); } /* ---- навигация ------------------------------------------------- */ const navData = () => { const anon = isAnon(); const groups = [{ label:'', items:[ { ic:'home', t: anon ? 'Вход' : 'Главная', view:'home' }, { ic:'book', t:'База знаний', view:'kb' }, ...(anon ? [] : [{ ic:'user', t:'Профиль', view:'profile' }]), ]}]; groups.push({ label:'Сообщество', items:[ { ic:'users', t:'hakku.ai/community', href:'https://hakku.ai/community', ext:true }, ]}); return groups; }; function SideFoot() { const { user, member } = ME(); const initialsOf = (n) => (n || '').split(/[\s@.]+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase(); if (!user) return ; return (
{initialsOf(user.name || user.email)}
{user.name || user.email}
{member.state === 'active' ? TIER_LABEL[member.tier] : 'без подписки'} · {user.email}
{ if (e.key === 'Enter') osLogout(); }}>Выйти
); } function Sidebar({ active = 'home', nav = ()=>{} }) { return ( ); } function Topbar({ crumbs, actions }) { return (
{crumbs.map((c, i) => ( {i > 0 && /} {i === crumbs.length - 1 ? {c} : {c}} ))}
{actions}
); } // Строка материала в списках (база, рекомендации, открытые материалы) function MatRow({ it, nav, first }) { return ( { e.preventDefault(); nav('kb', { m: it.slug }); }} style={first ? { borderTop: 0 } : null}>
{TYPE_LABEL[it.type] || 'Материал'} ·{clusterTitle(it.cluster)} {it.fixture && фикстура}
{it.title}
{it.summary}
{it.locked ? : {Icons.chev()}}
); } function MatCard({ it, nav }) { return ( { e.preventDefault(); nav('kb', { m: it.slug }); }}>
{TYPE_LABEL[it.type] || 'Материал'} {it.locked ? : {clusterTitle(it.cluster)}}

{it.title}

{it.summary}

); } // fire-and-forget аналитика экранов const track = (view) => { if (isAnon()) return; try { fetch('/api/track', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({ view }), keepalive: true }).catch(()=>{}); } catch (e) {} }; Object.assign(window, { HakkuGlyph, Wordmark, CoBrand, Icons, ME, CFG, isAnon, isMember, TIER_LABEL, STATUS_LABEL, fmtDate, fmtDay, firstName, useKB, clusterTitle, TYPE_LABEL, ssoLogin, osLogout, LoginButton, LockCard, LockBadge, MemberCard, MemberRail, Sidebar, Topbar, MatRow, MatCard, track });