// 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) => {p.children} ;
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 (
<>
{busy ? 'Открываем вход…' : label}
{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}
}
);
}
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 (
{!isAnon() &&
}
{children}
);
}
/* ---- навигация ------------------------------------------------- */
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 });