// ЗОВ MiniApp — главный скрипт. // На входе: подписанный initData от Telegram. // Ходим на backend → получаем профиль (роль, статус) → рендерим меню. const tg = window.Telegram?.WebApp; // Cloudflare Quick Tunnel → VPS FastAPI backend (GigaChat). // Временный URL — пока wasrusgen1.pro в verification-hold; затем переключим на https://api.wasrusgen1.pro const BACKEND_URL = "https://prepared-alfred-story-dale.trycloudflare.com"; const app = document.getElementById("app"); /* ----------------- Telegram WebApp setup ----------------- */ function setupTelegram() { const scheme = tg?.colorScheme || (window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"); document.documentElement.setAttribute("data-theme", scheme); // Зафиксирован вариант A — Editorial Calm document.documentElement.setAttribute("data-variant", "a"); if (!tg) return; try { tg.ready(); tg.expand(); if (tg.onEvent) tg.onEvent("themeChanged", () => { document.documentElement.setAttribute("data-theme", tg.colorScheme || "light"); }); if (tg.enableClosingConfirmation) tg.enableClosingConfirmation(); } catch (e) { console.warn(e); } } function haptic(type = "selection") { try { if (!tg?.HapticFeedback) return; if (type === "impact") tg.HapticFeedback.impactOccurred("light"); else if (type === "success") tg.HapticFeedback.notificationOccurred("success"); else tg.HapticFeedback.selectionChanged(); } catch (e) {} } /* ----------------- Data ----------------- */ async function fetchMe() { if (!BACKEND_URL) { // dev-режим без backend — мок для просмотра вёрстки return { role: "manager", user: { full_name: "Руслан Васильев", salon: "ЗОВ Москва", avatar_initial: "Р", }, status: "active", status_until: "12.08.2026", }; } // Apps Script Web App: путь через query-параметр. // Заголовок Content-Type НЕ ставим — иначе браузер шлёт CORS preflight, // который Apps Script не обрабатывает. Без заголовка fetch использует // text/plain — Apps Script всё равно парсит body как JSON. // Роль приходит в URL (?role=manager|client) — её бот подставляет в WebApp-кнопку const urlParams = new URLSearchParams(window.location.search); const explicitRole = urlParams.get("role"); const res = await fetch(`${BACKEND_URL}/api/me`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", // Fallback для Telegram Desktop side-panel где initData может приходить пустым. // Backend проверит подпись initData первым; если её нет — упадёт сюда. UNSAFE! initDataUnsafe: tg?.initDataUnsafe || null, startParam: tg?.initDataUnsafe?.start_param || null, role: explicitRole, }), }); if (!res.ok) throw new Error("backend HTTP " + res.status); return res.json(); } /* ----------------- Helpers ----------------- */ function el(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; } function statusLabel(s) { return ({ active: "Активен", lapsed: "Ограничен", grace: "Грейс", })[s] || s; } function getInitial(name) { return (name || "").trim().slice(0, 1).toUpperCase() || "?"; } /* ----------------- Renders ----------------- */ function renderManager(me) { // Новый главный экран — «утро менеджера» return renderManagerHome(me); } function timeOfDay(date = new Date()) { const h = date.getHours(); if (h >= 5 && h < 12) return "Доброе утро"; if (h >= 12 && h < 18) return "Добрый день"; if (h >= 18 && h < 23) return "Добрый вечер"; return "Доброй ночи"; } function pluralRu(n, forms) { // forms = ["замер", "замера", "замеров"] const mod10 = n % 10, mod100 = n % 100; if (mod100 >= 11 && mod100 <= 14) return forms[2]; if (mod10 === 1) return forms[0]; if (mod10 >= 2 && mod10 <= 4) return forms[1]; return forms[2]; } function renderManagerHome(me) { // === MOCK DATA (Этап 1 — визуал, без реального backend) === const firstName = (me.user?.full_name || "").split(/\s+/)[0] || "Артём"; const todayTask = { time: "15:30", tag: "ЗАМЕР", client: "А. Пестова", address: "ЖК Сады Пекина, корп. 3", phone: "+7 999 000-00-00", }; const projects = [ { name: "Семья Иваниковых", address: "ул. Орджоникидзе, 14 — 47", stage: "Согласование", date: "14 мая", progress: 0.40, statusLabel: "Ожидает клиента", statusKind: "waiting" }, { name: "Кабанова И. С.", address: "Никольская набережная, 20", stage: "Производство", date: "21 мая", progress: 0.60, statusLabel: "В работе", statusKind: "active" }, { name: "Карелин А.", address: "посёлок Сосновый, дом 4", stage: "Замер", date: "сегодня", progress: 0.10, statusLabel: "Срочно", statusKind: "urgent" }, { name: "Петросян Г.", address: "ул. Лесная, 18 — 12", stage: "Доставка", date: "16 мая", progress: 0.85, statusLabel: "В работе", statusKind: "active" }, { name: "Тимирясов И.", address: "пос. Барвиха, дом 8", stage: "Монтаж", date: "11 мая", progress: 0.95, statusLabel: "Завершается", statusKind: "active" }, ]; const unreadChats = 2; const tasksTodayCount = todayTask ? 1 : 0; const taskWord = pluralRu(tasksTodayCount, ["замер", "замера", "замеров"]); const phraseTail = tasksTodayCount === 0 ? "ничего на сегодня" : `${tasksTodayCount === 1 ? "один" : tasksTodayCount} ${taskWord} сегодня`; // === RENDER === app.innerHTML = ""; document.body.classList.add("has-bottom-nav"); // Greeting app.appendChild(el(`
${timeOfDay()}
${firstName},
${phraseTail}
`)); // Hero task if (todayTask) { app.appendChild(el(`
На сегодня${todayTask.time} ${todayTask.tag}
${todayTask.client}
${todayTask.address}
${ICONS.phone}
`)); } // Quick actions const quickActions = [ { icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" }, { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, { icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" }, { icon: "camera", title: "Замер сейчас", subtitle: "Заполнить вручную", href: "#/measure" }, ]; app.appendChild(el(`
Быстрые действия
`)); const grid = el(`
`); quickActions.forEach(qa => { const card = el(` `); card.addEventListener("click", () => { haptic("impact"); if (qa.href) location.hash = qa.href; else tg?.showAlert?.(`«${qa.title}» — скоро`); }); grid.appendChild(card); }); app.appendChild(grid); // Active projects app.appendChild(el(`
Активные проекты · ${projects.length} Все
`)); const list = el(`
`); projects.forEach(p => { const card = el(`
${p.name}
${p.statusLabel}
${p.address}
${p.stage} ${p.date}
`); card.addEventListener("click", () => { haptic("impact"); tg?.showAlert?.(`Проект «${p.name}» — скоро`); }); list.appendChild(card); }); app.appendChild(list); // Bottom nav (fixed, outside #app) renderBottomNav("home", { unreadChats }); } function renderBottomNav(active, opts = {}) { // Удаляем предыдущий, если есть const old = document.getElementById("bottom-nav"); if (old) old.remove(); const tabs = [ { key: "home", icon: "home", label: "Главная" }, { key: "projects", icon: "folder", label: "Проекты" }, { key: "fab", icon: "plus", label: "" }, { key: "chat", icon: "chat", label: "Чат", badge: opts.unreadChats || 0 }, { key: "profile", icon: "user", label: "Профиль" }, ]; const nav = el(``); tabs.forEach(t => { const isFab = t.key === "fab"; const isActive = t.key === active; const btn = el(` `); btn.addEventListener("click", () => { haptic("impact"); if (t.key !== active) tg?.showAlert?.(`«${t.label || "Новое"}» — скоро`); }); nav.appendChild(btn); }); document.body.appendChild(nav); } function renderClient(me) { const initial = me.user?.avatar_initial || getInitial(me.user?.full_name) || "?"; const greetName = me.user?.full_name || "Здравствуйте"; app.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); app.appendChild(el(`
Клиент
${greetName}
${me.manager ? "Менеджер: " + me.manager.full_name + (me.manager.salon ? ", " + me.manager.salon : "") : "ЗОВ — кухонная мебель"}
${initial}
`)); const sections = [ { label: "Подобрать кухню", items: [ { icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" }, { icon: "wrench", color: "green", label: "Подобрать технику", soon: true }, { icon: "wallet", color: "gold", label: "Калькулятор бюджета", soon: true }, ], }, { label: "Помощь", items: [ { icon: "lightbulb", color: "gold", label: "Идеи и кейсы", soon: true }, { icon: "phone", color: "blue", label: "Связаться с менеджером", href: "#/c/contact" }, { icon: "pin", color: "green", label: "Записаться в салон", soon: true }, ], }, ]; sections.forEach(section => { app.appendChild(el(`
${section.label}
`)); app.appendChild(buildMenu(section.items)); }); app.appendChild(el(` `)); } function buildMenu(items) { const menu = el(``); items.forEach(item => { const cls = item.soon ? "menu-item disabled" : "menu-item"; const node = el(`
${ICONS[item.icon] || ""}
${item.label} ${item.soon ? 'скоро' : ""}
${item.sub ? `
${item.sub}
` : ""}
${item.soon ? "" : `
${ICONS.chevron}
`}
`); if (!item.soon) node.addEventListener("click", () => haptic("impact")); menu.appendChild(node); }); 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(`
ЗОВ — кухня и техника

Кто вы?

Выберите роль — кабинет откроется одним тапом.

Свой выбор можно изменить позже в профиле.

`)); 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 = `
Открываем кабинет
`; document.body.appendChild(splashEl); init(); }); }); } /* ----------------- Staff (замерщик / сборщик) ----------------- */ async function renderStaff(me) { app.innerHTML = ""; if (me.error === "no_staff_role") { app.appendChild(el(`
🔒

У вас нет
прав сотрудника

Чтобы получить роль замерщика или сборщика — отправьте куратору ваш Telegram ID.

Ваш ID ${me.user?.tg_id || "—"}
Имя ${me.user?.full_name || "—"}

В боте отправьте /whoami и перешлите ответ @wasrusgen.

`)); return; } const caps = me.capabilities || {}; const labels = []; if (caps.measurer) labels.push("замерщик"); if (caps.assembler) labels.push("сборщик"); const subtitle = labels.length ? labels.join(" · ") : "сотрудник"; app.appendChild(el(`
${me.user?.avatar_initial || "?"}
${subtitle}

${me.user?.full_name || "Сотрудник"}

`)); // Реальный инбокс — загружаем из /api/measurement_inbox const inboxSection = el(`
📥 Входящие заявки на замер
`); app.appendChild(inboxSection); if (caps.measurer) { try { const res = await fetch(`${BACKEND_URL}/api/measurement_inbox`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "" }), }); const data = await res.json(); const list = document.getElementById("inboxList"); if (!list) return; if (data.error) { list.innerHTML = `
Ошибка: ${data.error}
`; } else if (!data.measurements || !data.measurements.length) { list.innerHTML = `
Заявок пока нет. Когда менеджер назначит замер — увидите здесь.
`; } else { list.innerHTML = ""; data.measurements.forEach(m => list.appendChild(renderInboxItem(m))); } } catch (e) { const list = document.getElementById("inboxList"); if (list) list.innerHTML = `
Сеть: ${e.message}
`; } } else { document.getElementById("inboxList").innerHTML = `
У вас только роль «сборщик» — инбокс заявок на сборку появится позже.
`; } // Quick action — заполнить замер без заявки (вне очереди) if (caps.measurer) { const quick = el(`
`); quick.querySelector("#newMeasure").addEventListener("click", () => { haptic && haptic("impact"); location.hash = "#/measure"; }); app.appendChild(quick); } } function renderInboxItem(m) { const statusLabel = ({ requested: "🟡 ждёт даты", scheduled: "📅 назначен", in_progress: "🔵 в работе", })[m.status] || m.status; const sched = m.scheduled_at ? formatDateHuman(m.scheduled_at) : "дата не назначена"; const item = el(` `); item.addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/inbox/${m.id}`; }); return item; } function formatDateHuman(iso) { if (!iso) return "—"; try { const d = new Date(iso); const dd = String(d.getDate()).padStart(2, "0"); const mm = String(d.getMonth() + 1).padStart(2, "0"); const yy = d.getFullYear(); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); return `${dd}.${mm}.${yy} ${hh}:${mi}`; } catch (e) { return iso; } } function escHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } /* ----------------- Карточка заявки для замерщика ----------------- */ async function renderInboxDetail(measurementId) { app.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); // header const header = el(`
Заявка на замер
`); header.querySelector(".podbor-back").addEventListener("click", () => { location.hash = ""; if (!location.hash) location.reload(); }); app.appendChild(header); const loading = el(`
`); app.appendChild(loading); let m; try { const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", measurement_id: measurementId }), }); m = await res.json(); } catch (e) { loading.remove(); app.appendChild(el(`
Сеть: ${e.message}
`)); return; } loading.remove(); if (m.error) { app.appendChild(el(`
${m.error}
`)); return; } // Шапка app.appendChild(el(`
Заявка #${(m.id || "").slice(0, 8)}

${escHtml(m.client_name || "Без имени")}

📞 ${escHtml(m.client_phone || "—")} 📍 ${escHtml(m.address || "адрес не указан")}
`)); // Заметки от менеджера if (m.notes) { app.appendChild(el(`
Заметки от менеджера
${escHtml(m.notes).replace(/\n/g, "
")}
`)); } // Блок «назначить дату» (если ещё requested) или «изменить дату» (если scheduled) const isScheduled = m.status === "scheduled"; const schedSection = el(`
${isScheduled ? "Дата замера" : "Назначить дату"}
`); app.appendChild(schedSection); schedSection.querySelector("#saveSched").addEventListener("click", async () => { const input = schedSection.querySelector("#schedInput"); const errorEl = schedSection.querySelector("#schedError"); errorEl.textContent = ""; const val = input.value; if (!val) { errorEl.textContent = "Укажите дату и время"; return; } const iso = new Date(val).toISOString(); try { const res = await fetch(`${BACKEND_URL}/api/measurement_schedule`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", measurement_id: measurementId, scheduled_at: iso, }), }); const data = await res.json(); if (data.error) { errorEl.textContent = "Ошибка: " + data.error; return; } haptic && haptic("success"); tg?.showAlert?.("Дата назначена — менеджер уведомлён."); renderInboxDetail(measurementId); // перерисовать } catch (e) { errorEl.textContent = "Сеть: " + e.message; } }); // Кнопка «Сделать замер» (только если назначено или прямо сейчас) const measureBtn = el(`
`); measureBtn.querySelector("#goMeasure").addEventListener("click", () => { haptic && haptic("impact"); // Передаём measurement_id чтобы wizard работал в update-mode location.hash = `#/measure?id=${measurementId}`; }); app.appendChild(measureBtn); } function toDatetimeLocalValue(iso) { // ISO → YYYY-MM-DDTHH:MM для try { const d = new Date(iso); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } catch (e) { return ""; } } function renderError() { app.innerHTML = ""; app.appendChild(el(`

Не удалось загрузить кабинет

Проверьте подключение и попробуйте позже.
`)); } /* ----------------- Init ----------------- */ // Засекаем когда стартовали — чтобы splash висел минимум ~700мс const _splashStart = Date.now(); function hideSplash() { const splash = document.getElementById("splash"); if (!splash) return; const elapsed = Date.now() - _splashStart; const minShow = 1200; // минимум показа, мс — 1.2 сек хватает чтобы рассмотреть лого и не блокировать UI const wait = Math.max(0, minShow - elapsed); setTimeout(() => { splash.classList.add("hide"); setTimeout(() => splash.remove(), 450); }, wait); } async function init() { setupTelegram(); window.addEventListener("hashchange", routeByHash); const qp = new URLSearchParams(window.location.search); const goScreen = qp.get("go"); if (goScreen && !location.hash) { const map = { podbor: "#/podbor", clients: "#/clients", measure: "#/measure", request: "#/request", }; if (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 { const me = await fetchMe(); 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 (location.hash.startsWith("#/request")) { MeasurementRequest.mount(app); hideSplash(); return; } if (location.hash.startsWith("#/inbox/")) { const id = location.hash.replace("#/inbox/", ""); renderInboxDetail(id); hideSplash(); return; } if (me.role === "staff") { renderStaff(me); } else if (me.role === "manager") { renderManager(me); } else { renderClient(me); } hideSplash(); } catch (e) { console.error(e); renderError(); hideSplash(); } } function routeByHash() { if (location.hash.startsWith("#/podbor")) { Podbor.mount(app); } else if (location.hash.startsWith("#/clients")) { Clients.mount(app); } else if (location.hash.startsWith("#/measure")) { Measurements.mount(app); } else if (location.hash.startsWith("#/request")) { MeasurementRequest.mount(app); } else if (location.hash.startsWith("#/inbox/")) { renderInboxDetail(location.hash.replace("#/inbox/", "")); } else { // Главный экран по роли const me = window.__zovMe; if (!me) { init(); return; } if (me.role === "staff") renderStaff(me); else if (me.role === "manager") renderManager(me); else renderClient(me); } } init();