universal entry: bot menu button + role chooser в MiniApp

Перешли на единый универсальный паттерн вместо reply/inline-keyboard:

1. Bot menu-button — постоянная кнопка «ЗОВ» слева от input в чате
   (set_chat_menu_button с WebAppInfo). Видна на ВСЕХ платформах:
   Telegram Desktop, iOS, Android, Web. Один тап — открывает MiniApp.

2. MiniApp без ?role= в URL показывает role chooser как первый экран:
   три большие карточки [Я менеджер] [Я клиент] [Я сотрудник].
   Тап → URL получает ?role=X → re-run init() → загрузка кабинета
   с правильно подписанным initData.

Решение универсальное — не зависит от reply/inline-кнопок и их
поведения с initData на разных клиентах Telegram.

Cache bust v=20260513n.
This commit is contained in:
wasrusgen 2026-05-13 07:36:55 +03:00
parent d6fbc3df13
commit b352c4927f
4 changed files with 181 additions and 14 deletions

View File

@ -4,6 +4,7 @@ import logging
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
from aiogram.types import MenuButtonWebApp, WebAppInfo
from config import load_config from config import load_config
from handlers import start from handlers import start
@ -28,6 +29,19 @@ async def main() -> None:
if config.use_webhook: if config.use_webhook:
raise NotImplementedError("Webhook mode будет добавлен после MVP") raise NotImplementedError("Webhook mode будет добавлен после MVP")
# Универсальная меню-кнопка — открывает MiniApp одним тапом.
# Внутри MiniApp пользователь выбирает роль (менеджер/клиент/сотрудник).
try:
await bot.set_chat_menu_button(
menu_button=MenuButtonWebApp(
text="ЗОВ",
web_app=WebAppInfo(url=config.miniapp_url),
),
)
logging.info("Установлена меню-кнопка MiniApp: %s", config.miniapp_url)
except Exception as e:
logging.warning("Не удалось установить меню-кнопку: %s", e)
logging.info("Запуск в режиме polling") logging.info("Запуск в режиме polling")
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot) await dp.start_polling(bot)

View File

@ -346,6 +346,70 @@ function buildMenu(items) {
return menu; return menu;
} }
/* ----------------- Role chooser — первый экран MiniApp ----------------- */
function renderRoleChooser() {
app.innerHTML = "";
document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove();
app.appendChild(el(`
<div class="role-chooser">
<div class="role-chooser-head">
<div class="kicker">ЗОВ кухня и техника</div>
<h1 class="display-title">Кто <span class="accent">вы?</span></h1>
<p class="lede">Выберите роль кабинет откроется одним тапом.</p>
</div>
<div class="role-cards">
<button class="role-card" data-role="manager">
<div class="role-icon">👤</div>
<div class="role-text">
<div class="role-title">Я менеджер</div>
<div class="role-sub">Веду клиентов и заказы</div>
</div>
<div class="role-arrow">${ICONS.chevron || ""}</div>
</button>
<button class="role-card" data-role="client">
<div class="role-icon">🏠</div>
<div class="role-text">
<div class="role-title">Я клиент</div>
<div class="role-sub">Заказал кухню ЗОВ</div>
</div>
<div class="role-arrow">${ICONS.chevron || ""}</div>
</button>
<button class="role-card" data-role="staff">
<div class="role-icon">🔧</div>
<div class="role-text">
<div class="role-title">Я сотрудник</div>
<div class="role-sub">Замерщик или сборщик ЗОВ</div>
</div>
<div class="role-arrow">${ICONS.chevron || ""}</div>
</button>
</div>
<p class="muted" style="text-align:center;margin-top:24px;font-size:12px;">
Свой выбор можно изменить позже в профиле.
</p>
</div>
`));
app.querySelectorAll(".role-card").forEach(card => {
card.addEventListener("click", () => {
const role = card.dataset.role;
haptic && haptic("impact");
// Меняем URL и перезапускаем init() — fetchMe пойдёт с правильной ролью
const qp = new URLSearchParams(window.location.search);
qp.set("role", role);
history.replaceState(null, "", `?${qp.toString()}${location.hash || ""}`);
// Показываем splash снова — на время загрузки
const splashEl = document.createElement("div");
splashEl.id = "splash";
splashEl.className = "loader splash";
splashEl.innerHTML = `<div class="loader-bar"></div><div class="loader-caption">Открываем кабинет</div>`;
document.body.appendChild(splashEl);
init();
});
});
}
/* ----------------- Staff (замерщик / сборщик) ----------------- */ /* ----------------- Staff (замерщик / сборщик) ----------------- */
async function renderStaff(me) { async function renderStaff(me) {
app.innerHTML = ""; app.innerHTML = "";
@ -658,11 +722,8 @@ function hideSplash() {
async function init() { async function init() {
setupTelegram(); setupTelegram();
// Hash-роутер: позволяет открывать подэкраны (например подбор) напрямую
window.addEventListener("hashchange", routeByHash); window.addEventListener("hashchange", routeByHash);
// ?go=podbor|clients|measure|request — бот может задать стартовый экран через query,
// потому что Telegram WebApp не передаёт hash через KeyboardButton.web_app.
const qp = new URLSearchParams(window.location.search); const qp = new URLSearchParams(window.location.search);
const goScreen = qp.get("go"); const goScreen = qp.get("go");
if (goScreen && !location.hash) { if (goScreen && !location.hash) {
@ -673,11 +734,18 @@ async function init() {
request: "#/request", request: "#/request",
}; };
if (map[goScreen]) { if (map[goScreen]) {
// Меняем hash без триггера hashchange (init сам отрендерит правильный экран)
history.replaceState(null, "", location.pathname + location.search + map[goScreen]); history.replaceState(null, "", location.pathname + location.search + map[goScreen]);
} }
} }
// Если нет ?role= в URL — показываем выбор роли (универсально для всех клиентов)
const explicitRole = qp.get("role");
if (!explicitRole && !location.hash) {
renderRoleChooser();
hideSplash();
return;
}
try { try {
const me = await fetchMe(); const me = await fetchMe();
window.__zovMe = me; // кешируем профиль для подэкранов window.__zovMe = me; // кешируем профиль для подэкранов

View File

@ -1989,6 +1989,91 @@
overflow-x: auto; overflow-x: auto;
} }
/* ===== Role chooser — первый экран ===== */
.role-chooser {
padding: 32px 18px;
max-width: 480px;
margin: 0 auto;
min-height: calc(100vh - 60px);
display: flex;
flex-direction: column;
justify-content: center;
}
.role-chooser-head {
text-align: center;
margin-bottom: 28px;
}
.role-chooser-head .kicker {
font-family: var(--font-mono, "JetBrains Mono", monospace);
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted, #998877);
margin-bottom: 12px;
}
.role-chooser-head .display-title {
font-size: 36px;
margin: 0 0 12px;
}
.role-chooser-head .lede {
font-size: 14px;
color: var(--ink-2, #6B5C4A);
margin: 0;
}
.role-cards {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.role-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 18px;
background: var(--card, #fff);
border: 1px solid var(--line-strong, rgba(15, 15, 14, 0.16));
border-radius: 16px;
cursor: pointer;
transition: background 0.15s, transform 0.1s;
text-align: left;
width: 100%;
font-family: inherit;
}
.role-card:active {
background: var(--paper-2, #F5EDDC);
transform: scale(0.985);
}
.role-card .role-icon {
font-size: 32px;
width: 52px;
height: 52px;
display: grid;
place-items: center;
background: var(--warm, rgba(107, 74, 43, 0.08));
border-radius: 14px;
flex-shrink: 0;
}
.role-card .role-text { flex: 1; min-width: 0; }
.role-card .role-title {
font-family: var(--font-display, "Newsreader", serif);
font-style: italic;
font-size: 20px;
color: var(--ink, #1F1A14);
margin-bottom: 2px;
}
.role-card .role-sub {
font-size: 12.5px;
color: var(--muted, #998877);
font-family: var(--font-mono, "JetBrains Mono", monospace);
letter-spacing: 0.02em;
}
.role-card .role-arrow {
color: var(--muted, #998877);
font-size: 22px;
flex-shrink: 0;
}
/* ===== Замер: фото с тегами ===== */ /* ===== Замер: фото с тегами ===== */
.podbor-header .podbor-help { .podbor-header .podbor-help {
background: transparent; background: transparent;

View File

@ -12,8 +12,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260513m"> <link rel="stylesheet" href="assets/styles.css?v=20260513n">
<link rel="stylesheet" href="assets/podbor.css?v=20260513m"> <link rel="stylesheet" href="assets/podbor.css?v=20260513n">
</head> </head>
<body> <body>
<!-- Splash — за пределами #app, render-функции его не смывают --> <!-- Splash — за пределами #app, render-функции его не смывают -->
@ -34,13 +34,13 @@
</div> </div>
</div> </div>
<main id="app"></main> <main id="app"></main>
<script src="assets/icons.js?v=20260513m"></script> <script src="assets/icons.js?v=20260513n"></script>
<script src="assets/podbor.config.js?v=20260513m"></script> <script src="assets/podbor.config.js?v=20260513n"></script>
<script src="assets/podbor.picts.js?v=20260513m"></script> <script src="assets/podbor.picts.js?v=20260513n"></script>
<script src="assets/podbor.js?v=20260513m"></script> <script src="assets/podbor.js?v=20260513n"></script>
<script src="assets/clients.js?v=20260513m"></script> <script src="assets/clients.js?v=20260513n"></script>
<script src="assets/measurements.js?v=20260513m"></script> <script src="assets/measurements.js?v=20260513n"></script>
<script src="assets/request.js?v=20260513m"></script> <script src="assets/request.js?v=20260513n"></script>
<script src="assets/app.js?v=20260513m"></script> <script src="assets/app.js?v=20260513n"></script>
</body> </body>
</html> </html>