Кнопочная навигация (консоль лидера)
Описание
Весь UX бота — кнопки (inline + одна persistent reply-клавиатура), команды —
только запасной вход. Ядро — единый резолвер состояния посетителя
(ViewerService) и два уровня гейтинга, которые определяют, какие разделы
физически рендерятся и какие проверки проходит каждый клик.
Основной код: apps/onboarding-bot/src/bot/viewer.service.ts,
apps/onboarding-bot/src/bot/menu.update.ts,
apps/onboarding-bot/src/leader-cabinet/.
Состояния посетителя (A/B/C)
ViewerService.classify(telegramId):
async classify(telegramId: string): Promise<Viewer> {
const bound = await this.magnetApi.checkAccountByTelegram(telegramId);
// ...
if (!bound) return { kind: 'A', lead, state: null };
const state = await this.magnetApi.getUserState(telegramId);
const hasNft = (state?.lp1 ?? 0) > 0 || (state?.lp2 ?? 0) > 0;
return { kind: hasNft ? 'C' : 'B', lead, state };
}
| Kind | Условие | Что видит пользователь |
|---|---|---|
| A | checkAccountByTelegram → false (не привязан) | Только онбординг-путь: продолжить знакомство, что такое Magnet, зарегистрироваться, подключить аккаунт, помощь |
| B | Привязан, lp1 == 0 && lp2 == 0 | Профиль-lite: следующий шаг до NFT, своя ссылка, уведомления, помощь + заглушка «🔒 Консоль лидера» |
| C | Привязан, lp1 > 0 || lp2 > 0 | Полная консоль лидера 2×3: Кабинет / Партнёры / Рассылки / Воронка / Ссылка / Уведомления |
Признак «привязан» — только GET /auth/check-account-by-telegram/:id, не
state == null (backend возвращает дефолтный объект состояния и для
непривязанного telegram_id, поэтому по state нельзя отличить A от
B/C — это явно задокументировано в коде classify()).
Viewer кэшируется в ctx.session.viewer на 60 секунд
(VIEWER_CACHE_TTL_MS), чтобы не бить backend на каждый тап внутри одного
захода в консоль. Инвалидация (ViewerService.invalidate) — при m:home и
после привязки/мгновенной отправки рассылки.
Два уровня гейта
- Гейт видимо сти —
renderMainMenu()собирает разный набор кнопок поkind. Разделы консоли (Кабинет/Партнёры/Рассылки/Воронка) физически не попадают в клавиатуру вне состояния C — не задизейблены, а отсутствуют. - Гейт исполнения —
ViewerService.requireLeaderConsole(ctx)вызывается первой строкой в каждом gated-хендлере (m:cabinet,m:partners,m:broadcasts,m:funnel, всеpt:*/fn:*/bc:*). Защищает от устаревшей клавиатуры: если лидер потерял NFT (продал/сжёг), старое сообщение с кнопкой «📣 Рассылки» в чате остаётся, но клик по ней получит отказ и меню перерисуется под актуальное состояние (A/B).
async requireLeaderConsole(ctx): Promise<boolean> {
const viewer = await this.resolveViewer(ctx);
if (viewer.kind !== 'C') {
await ctx.answerCbQuery('⛔️ Раздел доступен участникам со статусным NFT');
return false;
}
return true;
}
m:notify (уведомления) и m:link (своя ссылка) не гейтятся
requireLeaderConsole — доступны любому привязанному (B и C).
Скоуп данных: «тол ько своя структура»
Внутри состояния C все выборки идут через LeaderCabinetService со
скоупом leaderUid = resolveLeader(state.ownLink).uid:
where: [{ leaderUid }, { magnetParentUid: leaderUid }]
— т.е. участники, пришедшие по реф-ссылке лидера (leaderUid, first-touch,
неизменен) или зарегистрировавшиеся под ним как фактическим Magnet-
реферером (magnetParentUid, может отличаться от leaderUid при уходе к
другому лидеру). Рассылки используют тот же скоуп через
AdminService.segmentForLeader (см. «Рассылки»).
Дерево меню (как реализовано)
m:home ──► рендер зависит от resolveViewer (A/B/C)
[A]
🚀 Продолжить знакомство → m:resume
📽 Что такое Magnet → m:about
✅ Зарегистрироваться → reg
🔗 Подключить свой аккаунт → m:connect
❓ Помощь → m:help
[B]
🎯 Мой следующий шаг → m:progress (= m:upsell, один экран)
🔗 Моя ссылка → m:link
⚙️ Уведомления → m:notify
❓ Помощь → m:help
🔒 Консоль лидера → m:locked (заглушка → m:progress)
[C] — шапка: «🧑💼 Консоль лидера · Партнёров: {total} · 🔥 {hot} · зарег.: {registered}»
📊 Кабинет → m:cabinet | 👥 Партнёры → m:partners
📣 Рассылки → m:broadcasts| 📈 Воронка → m:funnel
🔗 Моя ссылка → m:link | ⚙️ Уведомления → m:notify
❓ Помощь → m:help
Под-экраны C:
m:cabinet— сводка (getMetrics+getHotLeads) + распределение по этапам (stuckByStage→stageLabel), кнопки на Партнёров/Воронку/Ссылку/Рассылки.m:partners/pt:list:<all|hot|stuck>:<page>— список партнёров (getPartners, сортировкаfunnelStage DESC, lastActivityAt DESC, лимит 50), пагинация по 10 (PARTNERS_PAGE_SIZE), стейтлесс (фильтр+страница закодированы вcallback_data).stuck= этап <RegisteredиlastActivityAtстарше 3 дней (STUCK_INACTIVE_DAYS) либо отсутствует.pt:u:<botUserId>— карточка партнёра (этап, интерес, кликов регистрации, запрошена ли помощь, последняя активность) с кнопкой «📣 Написать сегменту» → предвыбранный сегментbc:seg:presoмастера рассылки (в v1 нет прямого DM конкретному человеку — только сегментно, с уважением opt-in).m:funnel/fn:refresh— аналитика конверсии, см. ниже.m:link—getOwnLink(telegramId): deep-link в бота (https://t.me/<ONBOARDING_BOT_USERNAME>?start=<ownLink>)- реф-ссылка на сайт (
${FRONT_URL}/?link=<ownLink>) + QR-код (qrcodeпакет,QRCode.toBuffer) картинкой; при сбое генерации QR — фоллбэк на текст со ссылками без картинки.
- реф-ссылка на сайт (
m:notify/opt:<urgent|digest|report|marketing>— 4 boolean-тумблераbot_users(optinUrgent/optinDigest/optinReport/optinMarketing, defaulttrue), инверсия по клику +editMessageReplyMarkupна месте.m:help/m:help_ask— короткий экран + кнопка «Задать вопрос»: ставитhelpRequested = true(попадает вgetHotLeadsлидера) и шлёт DM лидеру структуры (magnetParentUid ?? leaderUid), если тот привязан в боте и не отключилoptinUrgent.
Аналитика воронки (m:funnel) считается через
LeaderCabinetService.conversionForLeader(leaderUid): getMetrics не отдаёт
готовые n1..n9, только stuckByStage[stage] (сколько сейчас ровно на
этапе) — кумулятив «дошёл до этапа ≥ N» строится суффиксной суммой в коде,
pct — доля от предыдущего этапа, «самый узкий переход» — минимальный pct
среди переходов 2→9. Чистый расчёт поверх уже загруженных данных, без
дополнительных запросов к БД.
Рендер: reply vs inline, editMessageText
- Persistent reply-клавиатура
[☰ Меню][❓ Помощь](Markup.keyboard(...).resize()) ставится один раз отдельным сообщением на/startи остаётся в чате; перехват через@Hears('☰ Меню')/@Hears('❓ Помощь'). - Вся остальная навигация — inline-кнопки. Вход из reply-кнопки/команды
(
/menu) шлёт новое сообщение (present(ctx, edit=false, ...)), чтобы клавиатура оказалась внизу чата; навигацияm:*/pt:*/fn:*/bc:*внутри —editMessageText(«одно окно», меньше мусора в чате). Если правка невозможна (например, предыдущее сообщение — фото без текста),present()ловит исключение и шлёт новое сообщение фоллбэком. - Синяя menu-button слева от поля ввода (
setChatMenuButton) и список команд (setMyCommands) регистрируются один раз при бутстрапе (registerBotCommands,apps/onboarding-bot/src/bot/bot-commands.ts):/menu,/cabinet,/help,/start./admin_*(AdminUpdate) в публичный список намеренно не входят — скрытый супер-fallback, доступный толькоtelegram_idизADMIN_TELEGRAM_IDS.
Свод callback-неймспейсов
| Префикс | Назначение |
|---|---|
m:* | Меню/разделы (m:home, m:cabinet, m:partners, m:broadcasts, m:funnel, m:link, m:notify, m:help, m:progress/m:upsell, m:locked, m:connect, m:resume, m:about, m:help_ask) |
pt:* | Партнёры (pt:list:<all|hot|stuck>:<page>, pt:u:<id>) |
fn:* | Воронка (fn:refresh) |
bc:* | Мастер/список рассылки — см. «Рассылки» |
opt:* | Тумблеры уведомлений (opt:urgent|digest|report|marketing) |
| legacy | adv:<stage>, int:<interest>, reg, bind_yes:*, bind_no:*, cab_connect, cab_partners — не переименованы, остаются рабочими |
Отличия от исходного UX-дизайна (осознанные, зафиксированы в коде)
Документ docs/superpowers/specs/.../ux-design.md — проектный черновик до
реализации; фактический код (menu.update.ts, broadcast.update.ts)
местами упростил детали без потери функциональности:
- Пресеты расписания рассылки — реализовано 2 пресета (
bc:sched:1h,bc:sched:tmrw10), а не 4 из черновика; ручной ввод произвольного времени текстом (bc:sched:custom) не реализован.@Cron-подборщик (BroadcastSenderService.sweep) не зависит от количества пресетов — добавление новых не требует изменений в отправителе. - Reactivation/struct-overtake DM — обработчики в
EventHandlersServiceполностью готовы, но backend их пока не эмитит (см. «Известные ограничения»).