Перейти к основному содержимому

Кнопочная навигация (консоль лидера)

Описание

Весь 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):

apps/onboarding-bot/src/bot/viewer.service.ts
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УсловиеЧто видит пользователь
AcheckAccountByTelegramfalse (не привязан)Только онбординг-путь: продолжить знакомство, что такое 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 и после привязки/мгновенной отправки рассылки.

Два уровня гейта

  1. Гейт видимостиrenderMainMenu() собирает разный набор кнопок по kind. Разделы консоли (Кабинет/Партнёры/Рассылки/Воронка) физически не попадают в клавиатуру вне состояния C — не задизейблены, а отсутствуют.
  2. Гейт исполненияViewerService.requireLeaderConsole(ctx) вызывается первой строкой в каждом gated-хендлере (m:cabinet, m:partners, m:broadcasts, m:funnel, все pt:*/fn:*/bc:*). Защищает от устаревшей клавиатуры: если лидер потерял NFT (продал/сжёг), старое сообщение с кнопкой «📣 Рассылки» в чате остаётся, но клик по ней получит отказ и меню перерисуется под актуальное состояние (A/B).
apps/onboarding-bot/src/bot/viewer.service.ts
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) + распределение по этапам (stuckByStagestageLabel), кнопки на Партнёров/Воронку/Ссылку/Рассылки.
  • 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:linkgetOwnLink(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, default true), инверсия по клику + 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)
legacyadv:<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 их пока не эмитит (см. «Известные ограничения»).