From 1ca8b3a5a15e684ef433b7f05f13dedc79e7110f Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Tue, 12 May 2026 18:54:09 +0300 Subject: [PATCH] =?UTF-8?q?bot:=20role=20buttons=20=E2=86=92=20MiniApp=20d?= =?UTF-8?q?irectly=20+=20branded=20splash=20loader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bot/handlers/start.py | 183 +++--------------------------------- miniapp/assets/app.js | 15 +++ miniapp/assets/styles.css | 79 +++++++++++++++- miniapp/assets/zov-logo.svg | 9 ++ miniapp/index.html | 35 ++++--- 5 files changed, 136 insertions(+), 185 deletions(-) create mode 100644 miniapp/assets/zov-logo.svg diff --git a/bot/handlers/start.py b/bot/handlers/start.py index 8a0ccbd..d38b1cc 100644 --- a/bot/handlers/start.py +++ b/bot/handlers/start.py @@ -20,34 +20,31 @@ router = Router(name="start") # ============================================================ 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 "?" return f"{url}{sep}t={int(time.time())}" 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 "?" pairs = "&".join(f"{k}={v}" for k, v in params.items() if v) return f"{url}{sep}{pairs}" if pairs else url -def _wapp(miniapp_url: str, role: str, go: str = "") -> WebAppInfo: - """Build a WebAppInfo with role + optional ?go=.""" - return WebAppInfo(url=_bust_cache(_with_query(miniapp_url, role=role, go=go))) +def _wapp(miniapp_url: str, role: str) -> WebAppInfo: + return WebAppInfo(url=_bust_cache(_with_query(miniapp_url, role=role))) # ============================================================ -# Reply keyboards (3 уровня) +# Reply keyboard — выбор роли. Оба кнопки сразу открывают MiniApp. # ============================================================ -# Уровень 1 — выбор роли (плоские текстовые кнопки внизу) -def role_choice_kb() -> ReplyKeyboardMarkup: +def role_choice_kb(miniapp_url: str) -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( keyboard=[ [ - KeyboardButton(text="👤 Я менеджер"), - KeyboardButton(text="🏠 Я клиент"), + KeyboardButton(text="👤 Я менеджер", web_app=_wapp(miniapp_url, "manager")), + KeyboardButton(text="🏠 Я клиент", web_app=_wapp(miniapp_url, "client")), ], ], 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 # ============================================================ @@ -113,125 +61,16 @@ def client_kb(miniapp_url: str) -> ReplyKeyboardMarkup: async def cmd_start(message: Message, config: Config) -> None: await message.answer( "👋 Здравствуйте, я бот-помощник от Руслана ВАСИЛЬЕВА.\n\n" - "Выберите, кто вы — внизу появилась панель.", - reply_markup=role_choice_kb(), + "Выберите, кто вы — кабинет откроется одним тапом.", + reply_markup=role_choice_kb(config.miniapp_url), ) @router.message(Command("menu")) -async def cmd_menu(message: Message) -> None: - """Возвращает к выбору роли.""" - await message.answer("Выберите роль:", reply_markup=role_choice_kb()) +async def cmd_menu(message: Message, config: Config) -> None: + await message.answer("Выберите роль:", reply_markup=role_choice_kb(config.miniapp_url)) @router.message(Command("hide")) async def cmd_hide(message: Message) -> None: 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( - "Меню менеджера\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( - "Меню клиента\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( - "ZOV Tech Picker — что умеет бот:\n\n" - "🤖 Подбор техники — AI собирает 3-7 моделей под клиента, " - "со сравнением цен на 4 маркетплейсах, плюсами/минусами и ссылками\n\n" - "📐 Замеры кухни — мастер из 6 шагов: форма, размеры, окна/двери, фото, " - "сохраняется в карточку клиента\n\n" - "👥 Клиенты — история подборов и замеров по каждому клиенту, " - "с возможностью переоткрыть отчёт или скачать PDF\n\n" - "🏠 Кабинет — главный экран менеджера с задачами на сегодня\n\n" - "Подсказка: внизу постоянная панель — открывает нужный экран одним тапом." - ) - - -@router.message(F.text == "📞 Связь с куратором") -async def kb_contact_curator(message: Message) -> None: - await message.answer( - "Куратор сети:\n\n" - "👤 Руслан Васильев\n" - "Telegram: @wasrusgen\n" - "Канал партнёрской сети: @wasrusgen1\n\n" - "Пишите по любым вопросам — от подключения к боту до сложных подборов техники." - ) - - -@router.message(F.text == "📋 Чек-лист встречи") -async def kb_checklist(message: Message) -> None: - await message.answer( - "📋 Чек-лист встречи с клиентом\n\n" - "До встречи:\n" - "• Получить контакт и согласовать время\n" - "• Уточнить — новая кухня или замена техники\n" - "• Понять бюджет (премиум / средний / эконом)\n\n" - "На встрече:\n" - "1. Замер кухни (📐 Новый замер в боте)\n" - " — стены, потолок, площадь\n" - " — окна, двери, вытяжка, газ/электро\n" - " — 5-10 фото со всех углов\n\n" - "2. Образ жизни клиента (что готовит, как часто)\n" - "3. Категории техники нужны (холодильник / варочная / духовка / посудомойка / вытяжка / СВЧ / кофемашина)\n" - "4. Запустить 🤖 Подбор техники — получить 3-7 моделей за 30 сек\n\n" - "После встречи:\n" - "• Скачать PDF подбора → отправить клиенту\n" - "• Поставить замер и подбор в карточку клиента\n" - "• Следующий шаг: дизайн-проект кухни ЗОВ" - ) - - -# ============================================================ -# Текстовые кнопки меню клиента -# ============================================================ - -@router.message(F.text == "📞 Связь с менеджером") -async def kb_contact_manager(message: Message) -> None: - await message.answer( - "Ваш менеджер ЗОВ:\n\n" - "Связаться с менеджером можно через ваш кабинет — там указаны контакты " - "сотрудника, который ведёт ваш проект.\n\n" - "Если кабинет ещё не открывался — попросите менеджера прислать " - "приглашение или напишите куратору сети @wasrusgen." - ) - - -@router.message(F.text == "ℹ️ О сервисе") -async def kb_about_service(message: Message) -> None: - await message.answer( - "О сервисе ЗОВ\n\n" - "ЗОВ — фабрика кухонной мебели премиум-сегмента из Беларуси.\n\n" - "Этот бот помогает менеджерам ЗОВ:\n" - "• сделать замер вашей кухни\n" - "• подобрать встраиваемую технику под ваш бюджет и образ жизни\n" - "• сохранить всё в одном кабинете для совместной работы\n\n" - "🌐 zov.by · 📍 СПб / Москва · 💬 @wasrusgen1" - ) diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index c7d2c1b..962619b 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -354,6 +354,16 @@ function renderError() { } /* ----------------- 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() { setupTelegram(); // Hash-роутер: позволяет открывать подэкраны (например подбор) напрямую @@ -376,21 +386,26 @@ async function init() { window.__zovMe = me; // кешируем профиль для подэкранов if (location.hash.startsWith("#/podbor")) { Podbor.mount(app); + hideSplash(); return; } if (location.hash.startsWith("#/clients")) { Clients.mount(app); + hideSplash(); return; } if (location.hash.startsWith("#/measure")) { Measurements.mount(app); + hideSplash(); return; } if (me.role === "manager") renderManager(me); else renderClient(me); + hideSplash(); } catch (e) { console.error(e); renderError(); + hideSplash(); } } diff --git a/miniapp/assets/styles.css b/miniapp/assets/styles.css index b33a1a3..5505ec7 100644 --- a/miniapp/assets/styles.css +++ b/miniapp/assets/styles.css @@ -189,14 +189,70 @@ button { font: inherit; cursor: pointer; border: none; background: none; color: } /* ============================================================ - Loader + Loader (брендированный — логотип ЗОВ + дыхание) ============================================================ */ .loader { display: grid; 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 { width: 32px; height: 32px; @@ -208,6 +264,25 @@ button { font: inherit; cursor: pointer; border: none; background: none; color: @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 ============================================================ */ diff --git a/miniapp/assets/zov-logo.svg b/miniapp/assets/zov-logo.svg new file mode 100644 index 0000000..c793299 --- /dev/null +++ b/miniapp/assets/zov-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/miniapp/index.html b/miniapp/index.html index 29f4e92..b0d6a8b 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,21 +12,34 @@ - - + +
-
-
+
+ +
+
+
Открываем кабинет
+
ZOV · кухня и техника
+
- - - - - - - + + + + + + +