// ════════════════════════════════════════════════════════════════ // PulsePage — «Пульс стартапов», редакционный лонгрид (веб-глубина) // Источник: GET /api/pulse/latest (редакционный слой ПОВЕРХ Совета) // Обратная пирамида: лид → тейкэвеи → рынок → 9 продуктов (drill-down) // Журнальная подача: Playfair заголовки, Source Serif тело, bronze // ════════════════════════════════════════════════════════════════ function PulsePage() { const [data, setData] = React.useState(null); const [runs, setRuns] = React.useState([]); // история выпусков const [selTs, setSelTs] = React.useState(null); // выбранный выпуск const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); const [open, setOpen] = React.useState({}); // slug → раскрыт ли продукт const apiGet = (p) => (window.SDH_API ? window.SDH_API.get(p) : fetch(p).then(x => { if (!x.ok) throw new Error('HTTP ' + x.status); return x.json(); })); React.useEffect(() => { let cancelled = false; async function load() { try { const [latest, list] = await Promise.all([ apiGet('/api/pulse/latest'), apiGet('/api/pulse/list').catch(() => []), ]); if (cancelled) return; setData(latest); setRuns(Array.isArray(list) ? list : []); setSelTs((latest.meta && latest.meta.pulse_ts) || null); setErr(null); } catch (e) { if (!cancelled) setErr(String(e.message || e)); } finally { if (!cancelled) setLoading(false); } } load(); return () => { cancelled = true; }; }, []); // Переключение выпуска из истории async function selectIssue(ts) { if (!ts || ts === selTs) return; setLoading(true); try { const d = await apiGet('/api/pulse/' + ts); setData(d); setSelTs(ts); setOpen({}); setErr(null); window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { setErr(String(e.message || e)); } finally { setLoading(false); } } const confColor = (c) => c >= 8 ? 'var(--hue-emerald)' : c >= 6 ? 'var(--hue-amber)' : 'var(--text-tertiary)'; const confLabel = (c) => `${c}/10`; if (loading) return (
готовлю выпуск…
); if (err || !data) return (
выпуск ещё не готов
{err || 'пульс не сгенерирован'}
запустите scripts/pulse_generate.py чтобы собрать выпуск из последнего совета.
); const m = data.meta || {}; const market = data.market || {}; return (
{/* ── Шапка выпуска ── */}
пульс стартапов
{runs.length > 1 && ( )}
выпуск от {m.council_date || '—'} · корпус {m.corpus_total || '—'} стартапов {m.council_scope === 'full' ? ' · полный охват' : ''} {runs.length > 1 ? ` · всего выпусков ${runs.length}` : ''}
{/* ── ЛИД (главное, обратная пирамида) ── */}

{data.lede}

{data.lede_confidence != null && ( уверенность {confLabel(data.lede_confidence)} )}
{/* ── ТЕЙКЭВЕИ ── */} {Array.isArray(data.takeaways) && data.takeaways.length > 0 && (
коротко о главном
    {data.takeaways.map((t, i) =>
  1. {t}
  2. )}
)} {/* ── РЫНОК: тренды / кластеры / белые пятна ── */}
{[ { key: 'trends', title: 'Макро-тренды', items: market.trends }, { key: 'clusters', title: 'Кластеры конкуренции', items: market.clusters }, { key: 'whitespace', title: 'Белые пятна', items: market.whitespace }, ].map(col => (

{col.title}

{(col.items || []).map((it, i) => (
{it.text} {it.confidence != null && {it.confidence}/10}
))}
))}
{/* ── ПОРТФЕЛЬ: 9 продуктов, drill-down ── */}
портфель: где каждый продукт стоит
{(data.portfolio || []).map((p) => { const isOpen = !!open[p.slug]; return (
setOpen(o => ({ ...o, [p.slug]: !o[p.slug] }))}>
{p.product}
{p.source === 'council' ? 'в фокусе совета' : 'по корпусу'} {p.confidence != null && ( {p.confidence}/10 )} {isOpen ? '−' : '+'}

{p.one_liner}

{isOpen && (
{p.where_stands &&
позиция{p.where_stands}
} {p.competition &&
конкуренция{p.competition}
} {p.action &&
действие{p.action}
}
)}
); })}
{/* ── Провенанс (честность) ── */}
сделано из экспертного разбора от {m.council_date || '—'} ({m.council_scope === 'full' ? 'полный корпус' : 'выборка'}) и корпуса в {m.corpus_total || '—'} стартапов из {m.corpus_sources || '—'} источников. каждое утверждение прослеживается до источника. это редакционный слой поверх совета, совет не меняется.
); } window.PulsePage = PulsePage;