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.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import MenuButtonWebApp, WebAppInfo
from config import load_config
from handlers import start
@ -28,6 +29,19 @@ async def main() -> None:
if config.use_webhook:
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")
await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot)

View File

@ -346,6 +346,70 @@ function buildMenu(items) {
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 (замерщик / сборщик) ----------------- */
async function renderStaff(me) {
app.innerHTML = "";
@ -658,11 +722,8 @@ function hideSplash() {
async function init() {
setupTelegram();
// Hash-роутер: позволяет открывать подэкраны (например подбор) напрямую
window.addEventListener("hashchange", routeByHash);
// ?go=podbor|clients|measure|request — бот может задать стартовый экран через query,
// потому что Telegram WebApp не передаёт hash через KeyboardButton.web_app.
const qp = new URLSearchParams(window.location.search);
const goScreen = qp.get("go");
if (goScreen && !location.hash) {
@ -673,11 +734,18 @@ async function init() {
request: "#/request",
};
if (map[goScreen]) {
// Меняем hash без триггера hashchange (init сам отрендерит правильный экран)
history.replaceState(null, "", location.pathname + location.search + map[goScreen]);
}
}
// Если нет ?role= в URL — показываем выбор роли (универсально для всех клиентов)
const explicitRole = qp.get("role");
if (!explicitRole && !location.hash) {
renderRoleChooser();
hideSplash();
return;
}
try {
const me = await fetchMe();
window.__zovMe = me; // кешируем профиль для подэкранов

View File

@ -1989,6 +1989,91 @@
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 {
background: transparent;

View File

@ -12,8 +12,8 @@
<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">
<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/podbor.css?v=20260513m">
<link rel="stylesheet" href="assets/styles.css?v=20260513n">
<link rel="stylesheet" href="assets/podbor.css?v=20260513n">
</head>
<body>
<!-- Splash — за пределами #app, render-функции его не смывают -->
@ -34,13 +34,13 @@
</div>
</div>
<main id="app"></main>
<script src="assets/icons.js?v=20260513m"></script>
<script src="assets/podbor.config.js?v=20260513m"></script>
<script src="assets/podbor.picts.js?v=20260513m"></script>
<script src="assets/podbor.js?v=20260513m"></script>
<script src="assets/clients.js?v=20260513m"></script>
<script src="assets/measurements.js?v=20260513m"></script>
<script src="assets/request.js?v=20260513m"></script>
<script src="assets/app.js?v=20260513m"></script>
<script src="assets/icons.js?v=20260513n"></script>
<script src="assets/podbor.config.js?v=20260513n"></script>
<script src="assets/podbor.picts.js?v=20260513n"></script>
<script src="assets/podbor.js?v=20260513n"></script>
<script src="assets/clients.js?v=20260513n"></script>
<script src="assets/measurements.js?v=20260513n"></script>
<script src="assets/request.js?v=20260513n"></script>
<script src="assets/app.js?v=20260513n"></script>
</body>
</html>