bot: role buttons → MiniApp directly + branded splash loader

Bot: упрощён до одного шага — /start показывает 2 reply-кнопки
[👤 Я менеджер] [🏠 Я клиент], обе уже WebApp — открывают кабинет
сразу с нужным role= в query. Никаких промежуточных меню.

MiniApp: новый брендированный загрузочный экран с логотипом ZOV
(inline SVG, fill = walnut #6B4A2B), дыхательной анимацией 2.2s,
тонкой полоской прогресса и подписью «Открываем кабинет · ZOV».
Splash прячется (350мс минимум + fade-out) после рендера главного
экрана или маунта подэкрана (Podbor/Clients/Measurements).

Cache bust v=20260513c.
This commit is contained in:
wasrusgen 2026-05-12 18:54:09 +03:00
parent 8f6b5e56bb
commit 1ca8b3a5a1
5 changed files with 136 additions and 185 deletions

View File

@ -20,34 +20,31 @@ router = Router(name="start")
# ============================================================ # ============================================================
def _bust_cache(url: str) -> str: def _bust_cache(url: str) -> str:
"""Append unique timestamp to MiniApp URL so Telegram WebView can't cache between sessions.""" """Append unique timestamp so Telegram WebView не кеширует между сессиями."""
sep = "&" if "?" in url else "?" sep = "&" if "?" in url else "?"
return f"{url}{sep}t={int(time.time())}" return f"{url}{sep}t={int(time.time())}"
def _with_query(url: str, **params: str) -> str: def _with_query(url: str, **params: str) -> str:
"""Append query params (e.g. role=manager, go=podbor) preserving existing ones."""
sep = "&" if "?" in url else "?" sep = "&" if "?" in url else "?"
pairs = "&".join(f"{k}={v}" for k, v in params.items() if v) pairs = "&".join(f"{k}={v}" for k, v in params.items() if v)
return f"{url}{sep}{pairs}" if pairs else url return f"{url}{sep}{pairs}" if pairs else url
def _wapp(miniapp_url: str, role: str, go: str = "") -> WebAppInfo: def _wapp(miniapp_url: str, role: str) -> WebAppInfo:
"""Build a WebAppInfo with role + optional ?go=<screen>.""" return WebAppInfo(url=_bust_cache(_with_query(miniapp_url, role=role)))
return WebAppInfo(url=_bust_cache(_with_query(miniapp_url, role=role, go=go)))
# ============================================================ # ============================================================
# Reply keyboards (3 уровня) # Reply keyboard — выбор роли. Оба кнопки сразу открывают MiniApp.
# ============================================================ # ============================================================
# Уровень 1 — выбор роли (плоские текстовые кнопки внизу) def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
def role_choice_kb() -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup( return ReplyKeyboardMarkup(
keyboard=[ keyboard=[
[ [
KeyboardButton(text="👤 Я менеджер"), KeyboardButton(text="👤 Я менеджер", web_app=_wapp(miniapp_url, "manager")),
KeyboardButton(text="🏠 Я клиент"), KeyboardButton(text="🏠 Я клиент", web_app=_wapp(miniapp_url, "client")),
], ],
], ],
resize_keyboard=True, resize_keyboard=True,
@ -56,55 +53,6 @@ def role_choice_kb() -> ReplyKeyboardMarkup:
) )
# Уровень 2a — меню менеджера (WebApp + текст)
def manager_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text="🤖 Подбор техники", web_app=_wapp(miniapp_url, "manager", "podbor")),
KeyboardButton(text="📐 Новый замер", web_app=_wapp(miniapp_url, "manager", "measure")),
],
[
KeyboardButton(text="👥 Мои клиенты", web_app=_wapp(miniapp_url, "manager", "clients")),
KeyboardButton(text="🏠 Кабинет", web_app=_wapp(miniapp_url, "manager")),
],
[
KeyboardButton(text=" Что умеет бот?"),
KeyboardButton(text="📞 Связь с куратором"),
],
[
KeyboardButton(text="📋 Чек-лист встречи"),
KeyboardButton(text="⬅️ Сменить роль"),
],
],
resize_keyboard=True,
is_persistent=True,
input_field_placeholder="Выберите действие…",
)
# Уровень 2b — меню клиента (WebApp + текст)
def client_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text="🏠 Мой кабинет", web_app=_wapp(miniapp_url, "client")),
KeyboardButton(text="📐 Мой замер", web_app=_wapp(miniapp_url, "client", "measure")),
],
[
KeyboardButton(text="📞 Связь с менеджером"),
KeyboardButton(text=" О сервисе"),
],
[
KeyboardButton(text="⬅️ Сменить роль"),
],
],
resize_keyboard=True,
is_persistent=True,
input_field_placeholder="Выберите действие…",
)
# ============================================================ # ============================================================
# Commands # Commands
# ============================================================ # ============================================================
@ -113,125 +61,16 @@ def client_kb(miniapp_url: str) -> ReplyKeyboardMarkup:
async def cmd_start(message: Message, config: Config) -> None: async def cmd_start(message: Message, config: Config) -> None:
await message.answer( await message.answer(
"👋 Здравствуйте, я бот-помощник от Руслана ВАСИЛЬЕВА.\n\n" "👋 Здравствуйте, я бот-помощник от Руслана ВАСИЛЬЕВА.\n\n"
"Выберите, кто вы — внизу появилась панель.", "Выберите, кто вы — кабинет откроется одним тапом.",
reply_markup=role_choice_kb(), reply_markup=role_choice_kb(config.miniapp_url),
) )
@router.message(Command("menu")) @router.message(Command("menu"))
async def cmd_menu(message: Message) -> None: async def cmd_menu(message: Message, config: Config) -> None:
"""Возвращает к выбору роли.""" await message.answer("Выберите роль:", reply_markup=role_choice_kb(config.miniapp_url))
await message.answer("Выберите роль:", reply_markup=role_choice_kb())
@router.message(Command("hide")) @router.message(Command("hide"))
async def cmd_hide(message: Message) -> None: async def cmd_hide(message: Message) -> None:
await message.answer("Клавиатура скрыта. Вернуть — /menu", reply_markup=ReplyKeyboardRemove()) await message.answer("Клавиатура скрыта. Вернуть — /menu", reply_markup=ReplyKeyboardRemove())
# ============================================================
# Уровень 1 → 2: выбор роли
# ============================================================
@router.message(F.text == "👤 Я менеджер")
async def role_manager(message: Message, config: Config) -> None:
await message.answer(
"<b>Меню менеджера</b>\n\n"
"Выбирайте действие — большинство кнопок открывают кабинет на нужном экране одним тапом.",
reply_markup=manager_kb(config.miniapp_url),
)
@router.message(F.text == "🏠 Я клиент")
async def role_client(message: Message, config: Config) -> None:
await message.answer(
"<b>Меню клиента</b>\n\n"
"Здесь видны ваш замер и личный кабинет от менеджера ЗОВ.",
reply_markup=client_kb(config.miniapp_url),
)
@router.message(F.text == "⬅️ Сменить роль")
async def back_to_role(message: Message) -> None:
await message.answer("Выберите роль:", reply_markup=role_choice_kb())
# ============================================================
# Текстовые кнопки меню менеджера
# ============================================================
@router.message(F.text == " Что умеет бот?")
async def kb_about(message: Message) -> None:
await message.answer(
"<b>ZOV Tech Picker — что умеет бот:</b>\n\n"
"🤖 <b>Подбор техники</b> — AI собирает 3-7 моделей под клиента, "
"со сравнением цен на 4 маркетплейсах, плюсами/минусами и ссылками\n\n"
"📐 <b>Замеры кухни</b> — мастер из 6 шагов: форма, размеры, окна/двери, фото, "
"сохраняется в карточку клиента\n\n"
"👥 <b>Клиенты</b> — история подборов и замеров по каждому клиенту, "
"с возможностью переоткрыть отчёт или скачать PDF\n\n"
"🏠 <b>Кабинет</b> — главный экран менеджера с задачами на сегодня\n\n"
"<i>Подсказка: внизу постоянная панель — открывает нужный экран одним тапом.</i>"
)
@router.message(F.text == "📞 Связь с куратором")
async def kb_contact_curator(message: Message) -> None:
await message.answer(
"<b>Куратор сети:</b>\n\n"
"👤 Руслан Васильев\n"
"Telegram: @wasrusgen\n"
"Канал партнёрской сети: @wasrusgen1\n\n"
"Пишите по любым вопросам — от подключения к боту до сложных подборов техники."
)
@router.message(F.text == "📋 Чек-лист встречи")
async def kb_checklist(message: Message) -> None:
await message.answer(
"<b>📋 Чек-лист встречи с клиентом</b>\n\n"
"<b>До встречи:</b>\n"
"• Получить контакт и согласовать время\n"
"• Уточнить — новая кухня или замена техники\n"
"• Понять бюджет (премиум / средний / эконом)\n\n"
"<b>На встрече:</b>\n"
"1. Замер кухни (📐 Новый замер в боте)\n"
" — стены, потолок, площадь\n"
" — окна, двери, вытяжка, газ/электро\n"
" — 5-10 фото со всех углов\n\n"
"2. Образ жизни клиента (что готовит, как часто)\n"
"3. Категории техники нужны (холодильник / варочная / духовка / посудомойка / вытяжка / СВЧ / кофемашина)\n"
"4. Запустить 🤖 Подбор техники — получить 3-7 моделей за 30 сек\n\n"
"<b>После встречи:</b>\n"
"• Скачать PDF подбора → отправить клиенту\n"
"• Поставить замер и подбор в карточку клиента\n"
"• Следующий шаг: дизайн-проект кухни ЗОВ"
)
# ============================================================
# Текстовые кнопки меню клиента
# ============================================================
@router.message(F.text == "📞 Связь с менеджером")
async def kb_contact_manager(message: Message) -> None:
await message.answer(
"<b>Ваш менеджер ЗОВ:</b>\n\n"
"Связаться с менеджером можно через ваш кабинет — там указаны контакты "
"сотрудника, который ведёт ваш проект.\n\n"
"Если кабинет ещё не открывался — попросите менеджера прислать "
"приглашение или напишите куратору сети @wasrusgen."
)
@router.message(F.text == " О сервисе")
async def kb_about_service(message: Message) -> None:
await message.answer(
"<b>О сервисе ЗОВ</b>\n\n"
"ЗОВ — фабрика кухонной мебели премиум-сегмента из Беларуси.\n\n"
"Этот бот помогает менеджерам ЗОВ:\n"
"• сделать замер вашей кухни\n"
"• подобрать встраиваемую технику под ваш бюджет и образ жизни\n"
"• сохранить всё в одном кабинете для совместной работы\n\n"
"🌐 zov.by · 📍 СПб / Москва · 💬 @wasrusgen1"
)

View File

@ -354,6 +354,16 @@ function renderError() {
} }
/* ----------------- Init ----------------- */ /* ----------------- Init ----------------- */
function hideSplash() {
const splash = document.getElementById("splash");
if (!splash) return;
// Минимум 350мс показа, чтобы не было «вспышки»
setTimeout(() => {
splash.classList.add("hide");
setTimeout(() => splash.remove(), 450);
}, 200);
}
async function init() { async function init() {
setupTelegram(); setupTelegram();
// Hash-роутер: позволяет открывать подэкраны (например подбор) напрямую // Hash-роутер: позволяет открывать подэкраны (например подбор) напрямую
@ -376,21 +386,26 @@ async function init() {
window.__zovMe = me; // кешируем профиль для подэкранов window.__zovMe = me; // кешируем профиль для подэкранов
if (location.hash.startsWith("#/podbor")) { if (location.hash.startsWith("#/podbor")) {
Podbor.mount(app); Podbor.mount(app);
hideSplash();
return; return;
} }
if (location.hash.startsWith("#/clients")) { if (location.hash.startsWith("#/clients")) {
Clients.mount(app); Clients.mount(app);
hideSplash();
return; return;
} }
if (location.hash.startsWith("#/measure")) { if (location.hash.startsWith("#/measure")) {
Measurements.mount(app); Measurements.mount(app);
hideSplash();
return; return;
} }
if (me.role === "manager") renderManager(me); if (me.role === "manager") renderManager(me);
else renderClient(me); else renderClient(me);
hideSplash();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
renderError(); renderError();
hideSplash();
} }
} }

View File

@ -189,14 +189,70 @@ button { font: inherit; cursor: pointer; border: none; background: none; color:
} }
/* ============================================================ /* ============================================================
Loader Loader (брендированный логотип ЗОВ + дыхание)
============================================================ */ ============================================================ */
.loader { .loader {
display: grid; display: grid;
place-items: center; place-items: center;
min-height: 60vh; min-height: 100vh;
gap: 22px;
padding: 24px;
} }
/* Полноэкранный «splash» — фон карамельный, поверх плавающий логотип */
.loader.splash {
position: fixed;
inset: 0;
background: var(--paper, #FBF7F0);
z-index: 999;
animation: splashFadeIn 0.3s ease-out;
}
.loader-logo {
width: 88px;
height: auto;
display: block;
animation: logoBreath 2.2s ease-in-out infinite;
}
.loader-logo path { fill: var(--walnut, #6B4A2B); }
.loader-caption {
font-family: var(--font-display, "Newsreader", serif);
font-style: italic;
font-size: 17px;
color: var(--ink-2, #6B5C4A);
letter-spacing: 0.01em;
text-align: center;
opacity: 0.8;
}
.loader-caption-sub {
font-family: var(--font-mono, "JetBrains Mono", monospace);
font-size: 10.5px;
color: var(--muted, #998877);
text-transform: uppercase;
letter-spacing: 0.18em;
margin-top: 4px;
}
.loader-bar {
width: 140px;
height: 2px;
background: rgba(107, 74, 43, 0.12);
border-radius: 1px;
overflow: hidden;
position: relative;
}
.loader-bar::after {
content: "";
position: absolute;
top: 0; left: -40%;
width: 40%;
height: 100%;
background: linear-gradient(90deg, transparent, var(--walnut, #6B4A2B) 50%, transparent);
animation: loaderBarSlide 1.4s ease-in-out infinite;
}
/* Старый spinner — оставлен для подэкранов (.loader-inline) */
.spinner { .spinner {
width: 32px; width: 32px;
height: 32px; height: 32px;
@ -208,6 +264,25 @@ button { font: inherit; cursor: pointer; border: none; background: none; color:
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@keyframes logoBreath {
0%, 100% { transform: scale(1); opacity: 0.92; }
50% { transform: scale(1.04); opacity: 1; }
}
@keyframes loaderBarSlide {
0% { left: -40%; }
100% { left: 100%; }
}
@keyframes splashFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.loader.splash.hide {
animation: splashFadeOut 0.4s ease-out forwards;
}
@keyframes splashFadeOut {
to { opacity: 0; visibility: hidden; }
}
/* ============================================================ /* ============================================================
Profile card Profile card
============================================================ */ ============================================================ */

View File

@ -0,0 +1,9 @@
<svg width="128" height="117" viewBox="0 0 128 117" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M38.9357 44.8551C35.0934 46.5151 32.2412 48.1838 32.2412 48.1838L63.767 0L87.9468 35.8374C87.9468 35.8374 86.6265 35.0766 82.3187 34.6875C76.2759 34.1428 67.8802 35.5261 61.3381 37.0478C54.8213 38.5609 45.3171 42.0884 38.9357 44.8551Z" fill="#212121"/>
<path d="M90.8406 40.4902L101.149 56.4679C101.149 56.4679 92.9733 55.0154 85.8642 55.5601C78.755 56.1048 59.0186 58.2836 59.0186 58.2836C59.0186 58.2836 75.5559 49.75 82.1319 46.2917C88.7079 42.8506 90.8406 40.4902 90.8406 40.4902Z" fill="#212121"/>
<path d="M128 97.7108L108.264 67.3809C108.264 67.3809 108.086 71.3753 99.1995 76.4591C90.313 81.5429 84.2703 84.0848 72.7094 88.451C61.157 92.8085 49.4521 97.8924 49.4521 97.8924L128 97.7108Z" fill="#212121"/>
<path d="M0 98.0718H35.0889C35.0889 98.0718 60.4535 88.1117 75.1458 80.6416C88.01 74.1053 96.7526 67.8197 94.586 63.393C92.9864 60.1248 85.0479 60.1248 75.5605 60.1248C67.9773 60.1248 59.3956 60.756 54.9354 60.6695C48.8333 60.5485 50.8476 59.8222 52.9803 58.3092C54.5545 57.1939 70.0339 48.3058 73.9609 45.9628C79.5298 42.6341 77.3377 41 74.7903 41.7868C70.8041 43.0145 57.9567 48.8678 44.627 57.0469C31.2888 65.2173 21.5137 73.7508 14.0491 80.8405C6.57597 87.9042 0 98.0718 0 98.0718Z" fill="#212121"/>
<path d="M1.95514 105.926V102.84C1.95514 102.84 29.5709 102.658 32.77 102.658C35.9691 102.658 38.0426 104.37 38.0426 106.713C38.0426 109.073 35.3768 109.92 35.3768 109.92C35.3768 109.92 38.0426 110.776 38.0426 113.551C38.0426 115.427 36.68 117.001 32.77 117.001H1.77734V113.733H26.4903V111.736H11.3747V107.923H26.4903V105.969L1.95514 105.926Z" fill="#212121"/>
<path d="M70.5301 113.322H52.8418V105.878H70.5301V113.322ZM76.7084 102.523H46.6636C43.5576 102.523 41.0186 105.117 41.0186 108.29V110.918C41.0186 114.091 43.5576 116.685 46.6636 116.685H76.7084C79.8144 116.685 82.3534 114.091 82.3534 110.918V108.29C82.3534 105.117 79.8229 102.523 76.7084 102.523Z" fill="#212121"/>
<path d="M115.52 107.576H100.405V111.388H115.52V113.385H97.18V105.587L115.52 105.622V107.576ZM124.407 109.573C124.407 109.573 127.081 108.725 127.081 106.365C127.081 104.022 125.007 102.311 121.8 102.311H85.5684V116.662H121.8C125.71 116.662 127.081 115.088 127.081 113.212C127.081 110.437 124.407 109.573 124.407 109.573Z" fill="#212121"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -12,21 +12,34 @@
<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=20260513b"> <link rel="stylesheet" href="assets/styles.css?v=20260513c">
<link rel="stylesheet" href="assets/podbor.css?v=20260513b"> <link rel="stylesheet" href="assets/podbor.css?v=20260513c">
</head> </head>
<body> <body>
<main id="app"> <main id="app">
<div class="loader"> <div class="loader splash" id="splash">
<div class="spinner"></div> <svg class="loader-logo" viewBox="0 0 128 117" xmlns="http://www.w3.org/2000/svg" aria-label="ЗОВ">
<path d="M38.9357 44.8551C35.0934 46.5151 32.2412 48.1838 32.2412 48.1838L63.767 0L87.9468 35.8374C87.9468 35.8374 86.6265 35.0766 82.3187 34.6875C76.2759 34.1428 67.8802 35.5261 61.3381 37.0478C54.8213 38.5609 45.3171 42.0884 38.9357 44.8551Z"/>
<path d="M90.8406 40.4902L101.149 56.4679C101.149 56.4679 92.9733 55.0154 85.8642 55.5601C78.755 56.1048 59.0186 58.2836 59.0186 58.2836C59.0186 58.2836 75.5559 49.75 82.1319 46.2917C88.7079 42.8506 90.8406 40.4902 90.8406 40.4902Z"/>
<path d="M128 97.7108L108.264 67.3809C108.264 67.3809 108.086 71.3753 99.1995 76.4591C90.313 81.5429 84.2703 84.0848 72.7094 88.451C61.157 92.8085 49.4521 97.8924 49.4521 97.8924L128 97.7108Z"/>
<path d="M0 98.0718H35.0889C35.0889 98.0718 60.4535 88.1117 75.1458 80.6416C88.01 74.1053 96.7526 67.8197 94.586 63.393C92.9864 60.1248 85.0479 60.1248 75.5605 60.1248C67.9773 60.1248 59.3956 60.756 54.9354 60.6695C48.8333 60.5485 50.8476 59.8222 52.9803 58.3092C54.5545 57.1939 70.0339 48.3058 73.9609 45.9628C79.5298 42.6341 77.3377 41 74.7903 41.7868C70.8041 43.0145 57.9567 48.8678 44.627 57.0469C31.2888 65.2173 21.5137 73.7508 14.0491 80.8405C6.57597 87.9042 0 98.0718 0 98.0718Z"/>
<path d="M1.95514 105.926V102.84C1.95514 102.84 29.5709 102.658 32.77 102.658C35.9691 102.658 38.0426 104.37 38.0426 106.713C38.0426 109.073 35.3768 109.92 35.3768 109.92C35.3768 109.92 38.0426 110.776 38.0426 113.551C38.0426 115.427 36.68 117.001 32.77 117.001H1.77734V113.733H26.4903V111.736H11.3747V107.923H26.4903V105.969L1.95514 105.926Z"/>
<path d="M70.5301 113.322H52.8418V105.878H70.5301V113.322ZM76.7084 102.523H46.6636C43.5576 102.523 41.0186 105.117 41.0186 108.29V110.918C41.0186 114.091 43.5576 116.685 46.6636 116.685H76.7084C79.8144 116.685 82.3534 114.091 82.3534 110.918V108.29C82.3534 105.117 79.8229 102.523 76.7084 102.523Z"/>
<path d="M115.52 107.576H100.405V111.388H115.52V113.385H97.18V105.587L115.52 105.622V107.576ZM124.407 109.573C124.407 109.573 127.081 108.725 127.081 106.365C127.081 104.022 125.007 102.311 121.8 102.311H85.5684V116.662H121.8C125.71 116.662 127.081 115.088 127.081 113.212C127.081 110.437 124.407 109.573 124.407 109.573Z"/>
</svg>
<div class="loader-bar"></div>
<div>
<div class="loader-caption">Открываем кабинет</div>
<div class="loader-caption-sub">ZOV · кухня и техника</div>
</div>
</div> </div>
</main> </main>
<script src="assets/icons.js?v=20260513b"></script> <script src="assets/icons.js?v=20260513c"></script>
<script src="assets/podbor.config.js?v=20260513b"></script> <script src="assets/podbor.config.js?v=20260513c"></script>
<script src="assets/podbor.picts.js?v=20260513b"></script> <script src="assets/podbor.picts.js?v=20260513c"></script>
<script src="assets/podbor.js?v=20260513b"></script> <script src="assets/podbor.js?v=20260513c"></script>
<script src="assets/clients.js?v=20260513b"></script> <script src="assets/clients.js?v=20260513c"></script>
<script src="assets/measurements.js?v=20260513b"></script> <script src="assets/measurements.js?v=20260513c"></script>
<script src="assets/app.js?v=20260513b"></script> <script src="assets/app.js?v=20260513c"></script>
</body> </body>
</html> </html>