mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 14:04:48 +00:00
feat: самозамер #/c/selfmeasure — 5-шаговый мастер для клиента
- selfmeasure.js: тип кухни (SVG-карточки) → стены → коммуникации → фото → контакт+отправка
- cabinet.js: кнопка «📐 Самозамер кухни» → #/c/selfmeasure
- app.js: маршрут #/c/selfmeasure, guard _hashListenerAdded
- index.html: подключение selfmeasure.js v20260518k
- backend: /api/self_measure_submit (hot-patch на VPS)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ea04e042df
commit
78e332dd95
15
ROADMAP.md
15
ROADMAP.md
@ -1,5 +1,5 @@
|
||||
# ROADMAP — zov-tech CRM MiniApp
|
||||
> Обновляется агентами автоматически. Последнее обновление: 2026-05-18
|
||||
> Обновляется агентами автоматически. Последнее обновление: 2026-05-18 (вечер)
|
||||
|
||||
---
|
||||
|
||||
@ -29,13 +29,18 @@ Telegram MiniApp для 112 менеджеров салонов ЗОВ: подб
|
||||
- [x] Подбор через AI (GigaChat/Claude)
|
||||
- [x] Приватность клиентских данных (имена/телефоны скрыты визуально)
|
||||
- [x] 4 темы оформления: Default, Foundry, Boardroom, Atelier
|
||||
- [x] Экран #/me — профиль для всех ролей (менеджер / сотрудник / клиент)
|
||||
- [x] Экран #/master — входящие заявки для замерщика/сборщика
|
||||
- [x] Экран #/inbox — входящие задачи менеджера (решение по подбору)
|
||||
|
||||
### Качество
|
||||
- [x] 15-секундный таймаут на все fetch-запросы (все модули)
|
||||
- [x] CSS-линтер (запрещённые паттерны + WCAG-контраст)
|
||||
- [x] Smoke API тесты (12 эндпоинтов)
|
||||
- [x] Полный тест кабинета менеджера (19 сценариев)
|
||||
- [x] UI Playwright smoke (10 проверок JS-ошибок)
|
||||
- [x] UI Playwright smoke (15 проверок — все экраны включая #/inbox, #/me)
|
||||
- [x] CI: smoke-ui.yml — Playwright против GitHub Pages после каждого деплоя
|
||||
- [x] Docker login на VPS (без 429 при ребилде)
|
||||
|
||||
---
|
||||
|
||||
@ -50,9 +55,9 @@ Telegram MiniApp для 112 менеджеров салонов ЗОВ: подб
|
||||
## 📋 Бэклог (приоритизирован)
|
||||
|
||||
### Приоритет 1 — Завершение MVP менеджера
|
||||
- [ ] Экран «Мой статус» для менеджера (роль, активность, дата последней сделки)
|
||||
- [ ] Входящие задачи менеджера (`/api/manager_pending`) — экран уведомлений
|
||||
- [ ] Отгрузки и поступления склад (после решения Drive)
|
||||
- [x] Экран «Мой статус» — #/me реализован
|
||||
- [x] Входящие задачи менеджера — #/inbox реализован
|
||||
- [ ] Отгрузки и поступления склада (⏳ блокер: share Drive с zov-backend@zov-sborka.iam.gserviceaccount.com)
|
||||
|
||||
### Приоритет 2 — Клиентский кабинет
|
||||
- [ ] Базовый клиентский экран (сейчас только `#/picker`)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// ЗОВ MiniApp — главный скрипт. v20260518j
|
||||
// ЗОВ MiniApp — главный скрипт. v20260518k
|
||||
// На входе: подписанный initData от Telegram.
|
||||
// Ходим на backend → получаем профиль (роль, статус) → рендерим меню.
|
||||
|
||||
@ -1719,6 +1719,9 @@ function routeByHash() {
|
||||
} else {
|
||||
app.innerHTML = `<div class="error">Модуль не загружен</div>`;
|
||||
}
|
||||
} else if (location.hash === "#/c/selfmeasure") {
|
||||
if (typeof SelfMeasureScreen !== "undefined") SelfMeasureScreen.mount(app);
|
||||
else init();
|
||||
} else {
|
||||
// Главный экран по роли
|
||||
const me = window.__zovMe;
|
||||
|
||||
@ -184,6 +184,9 @@ const CabinetScreen = (function () {
|
||||
${renderManagerBlock(me.manager)}
|
||||
${renderProposalsBlock(proposalsData.proposals || [])}
|
||||
${renderAssembliesBlock(assembliesData.assemblies || [])}
|
||||
<div class="block" style="margin:12px 16px 0;">
|
||||
<button class="btn-secondary" data-href="#/c/selfmeasure" style="width:100%;">📐 Самозамер кухни</button>
|
||||
</div>
|
||||
<div style="height:32px;"></div>
|
||||
`;
|
||||
|
||||
|
||||
809
miniapp/assets/selfmeasure.js
Normal file
809
miniapp/assets/selfmeasure.js
Normal file
@ -0,0 +1,809 @@
|
||||
/* ============================================================
|
||||
Самозамер кухни — #/c/selfmeasure
|
||||
5-шаговый мастер: тип кухни → стены → коммуникации → фото → контакт
|
||||
============================================================ */
|
||||
|
||||
const SelfMeasureScreen = (function () {
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function _api(path, body = {}) {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 15000);
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/${path}`, {
|
||||
method: "POST", signal: ctrl.signal,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, ...body }),
|
||||
});
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") throw new Error("Сервер не отвечает");
|
||||
throw e;
|
||||
} finally { clearTimeout(t); }
|
||||
}
|
||||
|
||||
/* ---- SVG schematics for kitchen types ---- */
|
||||
const KITCHEN_SVGS = {
|
||||
straight: `<svg viewBox="0 0 100 80" width="100" height="80" fill="none" stroke="#3D7AB5" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Room outline -->
|
||||
<rect x="8" y="8" width="84" height="64" rx="1" stroke="#aaa" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
<!-- Wall top -->
|
||||
<line x1="8" y1="8" x2="92" y2="8" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Cabinets along top wall -->
|
||||
<rect x="10" y="10" width="16" height="10" rx="1"/>
|
||||
<rect x="30" y="10" width="16" height="10" rx="1"/>
|
||||
<rect x="50" y="10" width="16" height="10" rx="1"/>
|
||||
<rect x="70" y="10" width="16" height="10" rx="1"/>
|
||||
<!-- Label -->
|
||||
<text x="50" y="36" text-anchor="middle" font-size="10" fill="#3D7AB5" stroke="none" font-family="sans-serif">А</text>
|
||||
<line x1="14" y1="30" x2="86" y2="30" stroke="#3D7AB5" stroke-width="1" marker-end="url(#arr)"/>
|
||||
</svg>`,
|
||||
|
||||
l_shape: `<svg viewBox="0 0 100 80" width="100" height="80" fill="none" stroke="#3D7AB5" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Room outline -->
|
||||
<rect x="8" y="8" width="84" height="64" rx="1" stroke="#aaa" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
<!-- Wall top (А) -->
|
||||
<line x1="8" y1="8" x2="92" y2="8" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Wall left (Б) -->
|
||||
<line x1="8" y1="8" x2="8" y2="72" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Cabinets top -->
|
||||
<rect x="10" y="10" width="16" height="10" rx="1"/>
|
||||
<rect x="30" y="10" width="16" height="10" rx="1"/>
|
||||
<rect x="50" y="10" width="16" height="10" rx="1"/>
|
||||
<rect x="70" y="10" width="16" height="10" rx="1"/>
|
||||
<!-- Cabinets left -->
|
||||
<rect x="10" y="25" width="10" height="14" rx="1"/>
|
||||
<rect x="10" y="43" width="10" height="14" rx="1"/>
|
||||
<rect x="10" y="60" width="10" height="10" rx="1"/>
|
||||
<!-- Labels -->
|
||||
<text x="54" y="34" text-anchor="middle" font-size="9" fill="#3D7AB5" stroke="none" font-family="sans-serif">А</text>
|
||||
<text x="28" y="50" text-anchor="middle" font-size="9" fill="#3D7AB5" stroke="none" font-family="sans-serif">Б</text>
|
||||
</svg>`,
|
||||
|
||||
u_shape: `<svg viewBox="0 0 100 80" width="100" height="80" fill="none" stroke="#3D7AB5" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Room outline -->
|
||||
<rect x="8" y="8" width="84" height="64" rx="1" stroke="#aaa" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
<!-- Wall top (А) -->
|
||||
<line x1="8" y1="8" x2="92" y2="8" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Wall left (Б) -->
|
||||
<line x1="8" y1="8" x2="8" y2="72" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Wall right (В) -->
|
||||
<line x1="92" y1="8" x2="92" y2="72" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Cabinets top -->
|
||||
<rect x="10" y="10" width="14" height="10" rx="1"/>
|
||||
<rect x="28" y="10" width="14" height="10" rx="1"/>
|
||||
<rect x="46" y="10" width="14" height="10" rx="1"/>
|
||||
<rect x="64" y="10" width="14" height="10" rx="1"/>
|
||||
<!-- Cabinets left -->
|
||||
<rect x="10" y="25" width="10" height="12" rx="1"/>
|
||||
<rect x="10" y="41" width="10" height="12" rx="1"/>
|
||||
<rect x="10" y="57" width="10" height="12" rx="1"/>
|
||||
<!-- Cabinets right -->
|
||||
<rect x="82" y="25" width="10" height="12" rx="1"/>
|
||||
<rect x="82" y="41" width="10" height="12" rx="1"/>
|
||||
<rect x="82" y="57" width="10" height="12" rx="1"/>
|
||||
<!-- Labels -->
|
||||
<text x="50" y="34" text-anchor="middle" font-size="9" fill="#3D7AB5" stroke="none" font-family="sans-serif">А</text>
|
||||
<text x="28" y="52" text-anchor="middle" font-size="9" fill="#3D7AB5" stroke="none" font-family="sans-serif">Б</text>
|
||||
<text x="72" y="52" text-anchor="middle" font-size="9" fill="#3D7AB5" stroke="none" font-family="sans-serif">В</text>
|
||||
</svg>`,
|
||||
|
||||
island: `<svg viewBox="0 0 100 80" width="100" height="80" fill="none" stroke="#3D7AB5" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Room outline -->
|
||||
<rect x="8" y="8" width="84" height="64" rx="1" stroke="#aaa" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
<!-- Wall top (А) -->
|
||||
<line x1="8" y1="8" x2="92" y2="8" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Wall left (Б) -->
|
||||
<line x1="8" y1="8" x2="8" y2="72" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Cabinets top -->
|
||||
<rect x="10" y="10" width="16" height="10" rx="1"/>
|
||||
<rect x="30" y="10" width="16" height="10" rx="1"/>
|
||||
<rect x="50" y="10" width="16" height="10" rx="1"/>
|
||||
<rect x="70" y="10" width="16" height="10" rx="1"/>
|
||||
<!-- Cabinets left -->
|
||||
<rect x="10" y="25" width="10" height="14" rx="1"/>
|
||||
<rect x="10" y="43" width="10" height="14" rx="1"/>
|
||||
<!-- Island -->
|
||||
<rect x="32" y="42" width="40" height="22" rx="2" stroke-dasharray="0"/>
|
||||
<text x="52" y="56" text-anchor="middle" font-size="8" fill="#3D7AB5" stroke="none" font-family="sans-serif">Остров</text>
|
||||
<!-- Labels -->
|
||||
<text x="54" y="34" text-anchor="middle" font-size="9" fill="#3D7AB5" stroke="none" font-family="sans-serif">А</text>
|
||||
<text x="28" y="52" text-anchor="middle" font-size="9" fill="#3D7AB5" stroke="none" font-family="sans-serif">Б</text>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
const KITCHEN_LABELS = {
|
||||
straight: "Прямая",
|
||||
l_shape: "Угловая Г",
|
||||
u_shape: "Угловая П",
|
||||
island: "Островная",
|
||||
};
|
||||
|
||||
/* ---- Walls by kitchen type ---- */
|
||||
function getWalls(type) {
|
||||
if (type === "straight") return ["А"];
|
||||
if (type === "island") return ["А", "Б"];
|
||||
if (type === "l_shape") return ["А", "Б"];
|
||||
if (type === "u_shape") return ["А", "Б", "В"];
|
||||
return ["А"];
|
||||
}
|
||||
|
||||
/* ---- Step 1: Kitchen type ---- */
|
||||
function renderStep1(state) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.innerHTML = `
|
||||
<div class="block" style="margin:16px 16px 0;">
|
||||
<div class="block-head">Выберите тип кухни</div>
|
||||
</div>
|
||||
`;
|
||||
const grid = document.createElement("div");
|
||||
grid.style.cssText = "display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:12px 16px;";
|
||||
["straight", "l_shape", "u_shape", "island"].forEach(type => {
|
||||
const card = document.createElement("button");
|
||||
card.style.cssText = `
|
||||
display:flex;flex-direction:column;align-items:center;gap:6px;
|
||||
padding:12px 8px;border-radius:12px;border:2px solid var(--border);
|
||||
background:var(--surface);cursor:pointer;transition:border-color 0.2s,background 0.2s;
|
||||
`;
|
||||
if (state.kitchenType === type) {
|
||||
card.style.borderColor = "var(--accent)";
|
||||
card.style.background = "var(--accent-faint, rgba(61,122,181,0.08))";
|
||||
}
|
||||
card.innerHTML = `
|
||||
${KITCHEN_SVGS[type]}
|
||||
<div style="font-size:12px;font-weight:600;color:var(--ink);text-align:center;">
|
||||
${escHtml(KITCHEN_LABELS[type])}
|
||||
</div>
|
||||
`;
|
||||
card.addEventListener("click", () => {
|
||||
haptic && haptic("impact");
|
||||
state.kitchenType = type;
|
||||
// Re-render step
|
||||
const parent = wrap.parentNode;
|
||||
const newStep = renderStep1(state);
|
||||
parent.replaceChild(newStep, wrap);
|
||||
});
|
||||
grid.appendChild(card);
|
||||
});
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* ---- Step 2: Wall dimensions ---- */
|
||||
function renderStep2(state) {
|
||||
const walls = getWalls(state.kitchenType);
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
// SVG diagram
|
||||
const svgDiagram = buildWallDiagramSVG(state.kitchenType, walls);
|
||||
|
||||
wrap.innerHTML = `
|
||||
<div class="block" style="margin:16px 16px 0;">
|
||||
<div class="block-head">Размеры стен</div>
|
||||
<div style="text-align:center;padding:8px 0 4px;">${svgDiagram}</div>
|
||||
<div style="font-size:12px;color:var(--muted);margin-bottom:10px;">
|
||||
Измеряйте каждую стену от угла до угла (в сантиметрах).
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const fieldsWrap = document.createElement("div");
|
||||
fieldsWrap.style.cssText = "padding:0 16px;display:flex;flex-direction:column;gap:10px;";
|
||||
|
||||
if (!state.walls) state.walls = {};
|
||||
|
||||
walls.forEach(w => {
|
||||
const row = document.createElement("div");
|
||||
row.innerHTML = `
|
||||
<label style="display:block;font-size:13px;font-weight:600;color:var(--ink);margin-bottom:4px;">
|
||||
Стена ${escHtml(w)}
|
||||
</label>
|
||||
<input type="number" inputmode="numeric" min="1" max="99999"
|
||||
class="sm-input"
|
||||
placeholder="длина в см"
|
||||
value="${escHtml(state.walls[w] || "")}"
|
||||
style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;
|
||||
border:1.5px solid var(--border);background:var(--surface);
|
||||
color:var(--ink);font-size:15px;outline:none;">
|
||||
`;
|
||||
const inp = row.querySelector("input");
|
||||
inp.addEventListener("input", () => {
|
||||
state.walls[w] = inp.value.trim();
|
||||
});
|
||||
fieldsWrap.appendChild(row);
|
||||
});
|
||||
|
||||
if (state.kitchenType === "island") {
|
||||
const note = document.createElement("div");
|
||||
note.style.cssText = "font-size:12px;color:var(--muted);padding:4px 0;";
|
||||
note.textContent = "Для островной кухни укажите длину основной рабочей зоны (стены А и Б). Размеры острова уточним отдельно.";
|
||||
fieldsWrap.appendChild(note);
|
||||
}
|
||||
|
||||
wrap.appendChild(fieldsWrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildWallDiagramSVG(type, walls) {
|
||||
const w = 200, h = 140;
|
||||
const common = `viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" fill="none" xmlns="http://www.w3.org/2000/svg"`;
|
||||
|
||||
if (type === "straight") {
|
||||
return `<svg ${common}>
|
||||
<line x1="20" y1="30" x2="180" y2="30" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<line x1="20" y1="30" x2="20" y2="110" stroke="#aaa" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<line x1="180" y1="30" x2="180" y2="110" stroke="#aaa" stroke-width="1" stroke-dasharray="4,3"/>
|
||||
<!-- arrow -->
|
||||
<line x1="30" y1="70" x2="170" y2="70" stroke="#3D7AB5" stroke-width="1.5" stroke-dasharray="5,3"/>
|
||||
<polygon points="170,66 180,70 170,74" fill="#3D7AB5"/>
|
||||
<polygon points="30,66 20,70 30,74" fill="#3D7AB5"/>
|
||||
<text x="100" y="90" text-anchor="middle" font-size="14" fill="#3D7AB5" font-weight="600" font-family="sans-serif">А</text>
|
||||
</svg>`;
|
||||
}
|
||||
if (type === "l_shape") {
|
||||
return `<svg ${common}>
|
||||
<!-- А — top wall -->
|
||||
<line x1="30" y1="20" x2="180" y2="20" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Б — left wall -->
|
||||
<line x1="30" y1="20" x2="30" y2="120" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- corner dot -->
|
||||
<circle cx="30" cy="20" r="3" fill="#3D7AB5"/>
|
||||
<!-- А arrow -->
|
||||
<line x1="40" y1="45" x2="170" y2="45" stroke="#3D7AB5" stroke-width="1.5" stroke-dasharray="5,3"/>
|
||||
<polygon points="170,41 180,45 170,49" fill="#3D7AB5"/>
|
||||
<polygon points="40,41 30,45 40,49" fill="#3D7AB5"/>
|
||||
<text x="105" y="65" text-anchor="middle" font-size="13" fill="#3D7AB5" font-weight="600" font-family="sans-serif">А</text>
|
||||
<!-- Б arrow -->
|
||||
<line x1="55" y1="30" x2="55" y2="110" stroke="#3D7AB5" stroke-width="1.5" stroke-dasharray="5,3"/>
|
||||
<polygon points="51,110 55,120 59,110" fill="#3D7AB5"/>
|
||||
<polygon points="51,30 55,20 59,30" fill="#3D7AB5"/>
|
||||
<text x="72" y="80" text-anchor="middle" font-size="13" fill="#3D7AB5" font-weight="600" font-family="sans-serif">Б</text>
|
||||
</svg>`;
|
||||
}
|
||||
if (type === "u_shape") {
|
||||
return `<svg ${common}>
|
||||
<!-- А — top wall -->
|
||||
<line x1="30" y1="20" x2="170" y2="20" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Б — left wall -->
|
||||
<line x1="30" y1="20" x2="30" y2="120" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- В — right wall -->
|
||||
<line x1="170" y1="20" x2="170" y2="120" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<circle cx="30" cy="20" r="3" fill="#3D7AB5"/>
|
||||
<circle cx="170" cy="20" r="3" fill="#3D7AB5"/>
|
||||
<!-- А arrow -->
|
||||
<line x1="40" y1="48" x2="160" y2="48" stroke="#3D7AB5" stroke-width="1.5" stroke-dasharray="5,3"/>
|
||||
<polygon points="160,44 170,48 160,52" fill="#3D7AB5"/>
|
||||
<polygon points="40,44 30,48 40,52" fill="#3D7AB5"/>
|
||||
<text x="100" y="66" text-anchor="middle" font-size="13" fill="#3D7AB5" font-weight="600" font-family="sans-serif">А</text>
|
||||
<!-- Б arrow -->
|
||||
<line x1="54" y1="30" x2="54" y2="110" stroke="#3D7AB5" stroke-width="1.5" stroke-dasharray="5,3"/>
|
||||
<polygon points="50,110 54,120 58,110" fill="#3D7AB5"/>
|
||||
<polygon points="50,30 54,20 58,30" fill="#3D7AB5"/>
|
||||
<text x="69" y="82" text-anchor="middle" font-size="13" fill="#3D7AB5" font-weight="600" font-family="sans-serif">Б</text>
|
||||
<!-- В arrow -->
|
||||
<line x1="146" y1="30" x2="146" y2="110" stroke="#3D7AB5" stroke-width="1.5" stroke-dasharray="5,3"/>
|
||||
<polygon points="142,110 146,120 150,110" fill="#3D7AB5"/>
|
||||
<polygon points="142,30 146,20 150,30" fill="#3D7AB5"/>
|
||||
<text x="131" y="82" text-anchor="middle" font-size="13" fill="#3D7AB5" font-weight="600" font-family="sans-serif">В</text>
|
||||
</svg>`;
|
||||
}
|
||||
// island
|
||||
return `<svg ${common}>
|
||||
<!-- А — top wall -->
|
||||
<line x1="30" y1="20" x2="170" y2="20" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<!-- Б — left wall -->
|
||||
<line x1="30" y1="20" x2="30" y2="120" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<circle cx="30" cy="20" r="3" fill="#3D7AB5"/>
|
||||
<!-- island rect -->
|
||||
<rect x="70" y="70" width="80" height="40" rx="3" stroke="#3D7AB5" stroke-dasharray="4,3"/>
|
||||
<text x="110" y="94" text-anchor="middle" font-size="10" fill="#3D7AB5" font-family="sans-serif">Остров</text>
|
||||
<!-- А label -->
|
||||
<text x="100" y="50" text-anchor="middle" font-size="13" fill="#3D7AB5" font-weight="600" font-family="sans-serif">А</text>
|
||||
<!-- Б label -->
|
||||
<text x="52" y="80" text-anchor="middle" font-size="13" fill="#3D7AB5" font-weight="600" font-family="sans-serif">Б</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ---- Step 3: Communications ---- */
|
||||
function renderStep3(state) {
|
||||
const walls = getWalls(state.kitchenType);
|
||||
if (!state.comms) state.comms = { water: {}, gas: {}, electric: {} };
|
||||
if (state.commsSkipped === undefined) state.commsSkipped = false;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
// Skip button
|
||||
const skipBtn = document.createElement("button");
|
||||
skipBtn.className = "btn-secondary";
|
||||
skipBtn.style.cssText = "margin:16px 16px 0;width:calc(100% - 32px);display:block;";
|
||||
skipBtn.textContent = "⏭ Пропустить — только предварительный расчёт";
|
||||
skipBtn.addEventListener("click", () => {
|
||||
haptic && haptic("impact");
|
||||
state.commsSkipped = true;
|
||||
refreshStep3(wrap, state, walls);
|
||||
});
|
||||
wrap.appendChild(skipBtn);
|
||||
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.id = "step3-content";
|
||||
wrap.appendChild(contentDiv);
|
||||
|
||||
refreshStep3(wrap, state, walls);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function refreshStep3(wrap, state, walls) {
|
||||
const contentDiv = wrap.querySelector("#step3-content");
|
||||
contentDiv.innerHTML = "";
|
||||
|
||||
if (state.commsSkipped) {
|
||||
// Disclaimer
|
||||
const disc = document.createElement("div");
|
||||
disc.className = "block";
|
||||
disc.style.cssText = "margin:12px 16px 0;";
|
||||
disc.innerHTML = `
|
||||
<div class="block-head">Пропустить коммуникации</div>
|
||||
<label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;padding:8px 0;">
|
||||
<input type="checkbox" id="commsSkipCheck" style="margin-top:3px;width:18px;height:18px;flex-shrink:0;"
|
||||
${state.commsSkipConfirmed ? "checked" : ""}>
|
||||
<span style="font-size:13px;color:var(--ink);line-height:1.5;">
|
||||
Понимаю, что точность замера на моей ответственности. Коммуникации замерит специалист.
|
||||
</span>
|
||||
</label>
|
||||
<button class="btn-secondary btn-sm" style="margin-top:6px;" id="commsUnskipBtn">
|
||||
← Ввести коммуникации
|
||||
</button>
|
||||
`;
|
||||
disc.querySelector("#commsSkipCheck").addEventListener("change", e => {
|
||||
state.commsSkipConfirmed = e.target.checked;
|
||||
});
|
||||
disc.querySelector("#commsUnskipBtn").addEventListener("click", () => {
|
||||
haptic && haptic("impact");
|
||||
state.commsSkipped = false;
|
||||
state.commsSkipConfirmed = false;
|
||||
refreshStep3(wrap, state, walls);
|
||||
});
|
||||
contentDiv.appendChild(disc);
|
||||
} else {
|
||||
// Full comms form
|
||||
const block = document.createElement("div");
|
||||
block.className = "block";
|
||||
block.style.cssText = "margin:12px 16px 0;";
|
||||
block.innerHTML = `
|
||||
<div class="block-head">Коммуникации</div>
|
||||
<div style="font-size:12px;color:var(--muted);margin-bottom:10px;line-height:1.5;">
|
||||
Укажите расположение коммуникаций. Измеряйте расстояние от левого угла стены (смотря на стену лицом).
|
||||
</div>
|
||||
<div style="text-align:center;margin-bottom:10px;">
|
||||
${buildCommsHintSVG()}
|
||||
</div>
|
||||
`;
|
||||
contentDiv.appendChild(block);
|
||||
|
||||
// Water (always shown)
|
||||
contentDiv.appendChild(buildCommsSection("Вода 🚿", "water", state, walls, true));
|
||||
// Gas
|
||||
contentDiv.appendChild(buildCommsSectionGas(state, walls));
|
||||
// Electric
|
||||
contentDiv.appendChild(buildCommsSection("Электрика ⚡", "electric", state, walls, true));
|
||||
}
|
||||
}
|
||||
|
||||
function buildCommsHintSVG() {
|
||||
return `<svg viewBox="0 0 200 60" width="180" height="54" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="10" y1="10" x2="190" y2="10" stroke="#3D7AB5" stroke-width="3"/>
|
||||
<line x1="10" y1="10" x2="10" y2="50" stroke="#aaa" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<line x1="190" y1="10" x2="190" y2="50" stroke="#aaa" stroke-width="1" stroke-dasharray="3,2"/>
|
||||
<line x1="10" y1="38" x2="100" y2="38" stroke="#3D7AB5" stroke-width="1.5" stroke-dasharray="4,2"/>
|
||||
<polygon points="95,34 105,38 95,42" fill="#3D7AB5"/>
|
||||
<circle cx="105" cy="14" r="5" fill="#3D7AB5" opacity="0.7"/>
|
||||
<text x="105" y="55" text-anchor="middle" font-size="10" fill="#3D7AB5" font-family="sans-serif">от левого угла →</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildCommsSection(title, key, state, walls, alwaysShow) {
|
||||
if (!state.comms[key]) state.comms[key] = {};
|
||||
const section = document.createElement("div");
|
||||
section.className = "block";
|
||||
section.style.cssText = "margin:8px 16px 0;";
|
||||
section.innerHTML = `<div class="block-head">${escHtml(title)}</div>`;
|
||||
|
||||
const wallOpts = walls.map(w => `<option value="${w}" ${state.comms[key].wall === w ? "selected" : ""}>${w}</option>`).join("");
|
||||
|
||||
const posOpts = ["Левый угол", "Центр", "Правый угол"].map(p =>
|
||||
`<option value="${p}" ${state.comms[key].pos === p ? "selected" : ""}>${p}</option>`
|
||||
).join("");
|
||||
|
||||
const form = document.createElement("div");
|
||||
form.style.cssText = "display:flex;flex-direction:column;gap:8px;";
|
||||
form.innerHTML = `
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<label style="font-size:12px;color:var(--muted);width:60px;flex-shrink:0;">Стена</label>
|
||||
<select style="flex:1;padding:8px;border-radius:8px;border:1.5px solid var(--border);background:var(--surface);color:var(--ink);font-size:13px;" data-field="wall">
|
||||
${wallOpts}
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<label style="font-size:12px;color:var(--muted);width:60px;flex-shrink:0;">Позиция</label>
|
||||
<select style="flex:1;padding:8px;border-radius:8px;border:1.5px solid var(--border);background:var(--surface);color:var(--ink);font-size:13px;" data-field="pos">
|
||||
${posOpts}
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<label style="font-size:12px;color:var(--muted);width:60px;flex-shrink:0;">Расстояние</label>
|
||||
<input type="number" inputmode="numeric" placeholder="от левого угла, см" data-field="dist"
|
||||
value="${escHtml(state.comms[key].dist || "")}"
|
||||
style="flex:1;padding:8px 10px;border-radius:8px;border:1.5px solid var(--border);background:var(--surface);color:var(--ink);font-size:13px;">
|
||||
</div>
|
||||
`;
|
||||
form.querySelectorAll("[data-field]").forEach(inp => {
|
||||
const ev = inp.tagName === "SELECT" ? "change" : "input";
|
||||
inp.addEventListener(ev, () => {
|
||||
state.comms[key][inp.dataset.field] = inp.value;
|
||||
});
|
||||
// Init state
|
||||
if (!state.comms[key][inp.dataset.field] && inp.tagName === "SELECT") {
|
||||
state.comms[key][inp.dataset.field] = inp.value;
|
||||
}
|
||||
});
|
||||
section.appendChild(form);
|
||||
return section;
|
||||
}
|
||||
|
||||
function buildCommsSectionGas(state, walls) {
|
||||
if (!state.comms.gas) state.comms.gas = {};
|
||||
const section = document.createElement("div");
|
||||
section.className = "block";
|
||||
section.style.cssText = "margin:8px 16px 0;";
|
||||
|
||||
const hasGas = !!state.comms.gas.enabled;
|
||||
|
||||
section.innerHTML = `
|
||||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;padding:4px 0;" class="block-head">
|
||||
<input type="checkbox" id="gasCheck" ${hasGas ? "checked" : ""} style="width:18px;height:18px;">
|
||||
<span>Газ 🔥</span>
|
||||
</label>
|
||||
`;
|
||||
|
||||
const gasFields = document.createElement("div");
|
||||
gasFields.id = "gas-fields";
|
||||
gasFields.style.display = hasGas ? "block" : "none";
|
||||
section.appendChild(gasFields);
|
||||
|
||||
if (hasGas) {
|
||||
const inner = buildCommsSection("", "gas", state, walls, false);
|
||||
inner.style.margin = "0";
|
||||
inner.querySelector(".block-head") && (inner.querySelector(".block-head").style.display = "none");
|
||||
gasFields.appendChild(inner);
|
||||
}
|
||||
|
||||
section.querySelector("#gasCheck").addEventListener("change", e => {
|
||||
state.comms.gas.enabled = e.target.checked;
|
||||
if (e.target.checked) {
|
||||
gasFields.style.display = "block";
|
||||
gasFields.innerHTML = "";
|
||||
const inner = buildCommsSection("", "gas", state, walls, false);
|
||||
inner.style.margin = "0";
|
||||
const bh = inner.querySelector(".block-head");
|
||||
if (bh) bh.style.display = "none";
|
||||
gasFields.appendChild(inner);
|
||||
} else {
|
||||
gasFields.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
/* ---- Step 4: Photos ---- */
|
||||
function renderStep4() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.innerHTML = `
|
||||
<div class="block" style="margin:16px 16px 0;">
|
||||
<div class="block-head">Фотографии</div>
|
||||
<div style="text-align:center;padding:20px 0 8px;font-size:40px;">📸</div>
|
||||
<div style="font-size:14px;color:var(--ink);line-height:1.6;margin-bottom:12px;">
|
||||
Сфотографируйте каждую стену с рулеткой.
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--muted);line-height:1.5;padding:10px 12px;
|
||||
background:var(--surface-2,var(--surface));border-radius:8px;border:1px solid var(--border);">
|
||||
📤 Загрузка фото — только через Telegram. После отправки замера прикрепите фото ответным сообщением в чате с менеджером.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* ---- Step 5: Contact + submit ---- */
|
||||
function renderStep5(state, onSubmit) {
|
||||
if (!state.contact) state.contact = {};
|
||||
const wrap = document.createElement("div");
|
||||
|
||||
wrap.innerHTML = `
|
||||
<div class="block" style="margin:16px 16px 0;">
|
||||
<div class="block-head">Контактные данные</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;margin-top:4px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px;">Имя *</label>
|
||||
<input type="text" id="sm-name" placeholder="Ваше имя"
|
||||
value="${escHtml(state.contact.name || "")}"
|
||||
style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;
|
||||
border:1.5px solid var(--border);background:var(--surface);
|
||||
color:var(--ink);font-size:15px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px;">Телефон *</label>
|
||||
<input type="tel" id="sm-phone" placeholder="+7 (___) ___-__-__"
|
||||
value="${escHtml(state.contact.phone || "")}"
|
||||
style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;
|
||||
border:1.5px solid var(--border);background:var(--surface);
|
||||
color:var(--ink);font-size:15px;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:4px;">Адрес *</label>
|
||||
<input type="text" id="sm-address" placeholder="Улица, дом, кв."
|
||||
value="${escHtml(state.contact.address || "")}"
|
||||
style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;
|
||||
border:1.5px solid var(--border);background:var(--surface);
|
||||
color:var(--ink);font-size:15px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wrap.querySelector("#sm-name").addEventListener("input", e => state.contact.name = e.target.value.trim());
|
||||
wrap.querySelector("#sm-phone").addEventListener("input", e => state.contact.phone = e.target.value.trim());
|
||||
wrap.querySelector("#sm-address").addEventListener("input", e => state.contact.address = e.target.value.trim());
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* ---- Validation ---- */
|
||||
function validateStep(step, state) {
|
||||
if (step === 1) {
|
||||
return !!state.kitchenType;
|
||||
}
|
||||
if (step === 2) {
|
||||
const walls = getWalls(state.kitchenType);
|
||||
return walls.every(w => state.walls && parseInt(state.walls[w]) > 0);
|
||||
}
|
||||
if (step === 3) {
|
||||
if (state.commsSkipped) return !!state.commsSkipConfirmed;
|
||||
return true; // comms are optional when not skipped
|
||||
}
|
||||
if (step === 4) {
|
||||
return true; // photos always optional
|
||||
}
|
||||
if (step === 5) {
|
||||
const c = state.contact || {};
|
||||
return !!(c.name && c.phone && c.address);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getValidationMessage(step, state) {
|
||||
if (step === 1) return "Выберите тип кухни";
|
||||
if (step === 2) {
|
||||
const walls = getWalls(state.kitchenType);
|
||||
const missing = walls.filter(w => !state.walls || !parseInt(state.walls[w]));
|
||||
return `Введите длину стены: ${missing.join(", ")}`;
|
||||
}
|
||||
if (step === 3 && state.commsSkipped && !state.commsSkipConfirmed) {
|
||||
return "Подтвердите понимание или введите коммуникации";
|
||||
}
|
||||
if (step === 5) {
|
||||
const c = state.contact || {};
|
||||
if (!c.name) return "Введите имя";
|
||||
if (!c.phone) return "Введите телефон";
|
||||
if (!c.address) return "Введите адрес";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const STEP_TITLES = [
|
||||
"Тип кухни",
|
||||
"Размеры стен",
|
||||
"Коммуникации",
|
||||
"Фотографии",
|
||||
"Контакт",
|
||||
];
|
||||
|
||||
/* ---- Main mount ---- */
|
||||
async function mount(container) {
|
||||
container.innerHTML = "";
|
||||
document.body.classList.remove("has-bottom-nav");
|
||||
const oldNav = document.getElementById("bottom-nav");
|
||||
if (oldNav) oldNav.remove();
|
||||
|
||||
// State
|
||||
const state = {
|
||||
kitchenType: null,
|
||||
walls: {},
|
||||
commsSkipped: false,
|
||||
commsSkipConfirmed: false,
|
||||
comms: { water: {}, gas: {}, electric: {} },
|
||||
contact: {},
|
||||
step: 1,
|
||||
};
|
||||
|
||||
// Try pre-fill contact from /api/me
|
||||
try {
|
||||
const me = await _api("me");
|
||||
if (me && !me.error && me.user) {
|
||||
state.contact.name = me.user.full_name || "";
|
||||
state.contact.phone = me.user.phone || "";
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// Header
|
||||
const header = document.createElement("header");
|
||||
header.className = "podbor-header";
|
||||
header.innerHTML = `
|
||||
<button class="podbor-back" aria-label="Назад">${(window.ICONS || {}).arrow_left || "‹"}</button>
|
||||
<div class="podbor-title" id="sm-header-title">Шаг 1 / 5 — ${escHtml(STEP_TITLES[0])}</div>
|
||||
<div style="width:36px"></div>
|
||||
`;
|
||||
header.querySelector(".podbor-back").addEventListener("click", () => {
|
||||
haptic && haptic("impact");
|
||||
if (state.step > 1) {
|
||||
state.step--;
|
||||
renderCurrentStep();
|
||||
} else {
|
||||
history.back();
|
||||
}
|
||||
});
|
||||
container.appendChild(header);
|
||||
|
||||
// Progress bar
|
||||
const progressBar = document.createElement("div");
|
||||
progressBar.style.cssText = "height:3px;background:var(--border);margin:0;";
|
||||
const progressFill = document.createElement("div");
|
||||
progressFill.style.cssText = "height:3px;background:var(--accent);transition:width 0.3s;";
|
||||
progressBar.appendChild(progressFill);
|
||||
container.appendChild(progressBar);
|
||||
|
||||
// Screen
|
||||
const screen = document.createElement("div");
|
||||
screen.className = "podbor-screen";
|
||||
screen.style.cssText = "padding-bottom:80px;";
|
||||
container.appendChild(screen);
|
||||
|
||||
// Bottom navigation
|
||||
const bottomNav = document.createElement("div");
|
||||
bottomNav.style.cssText = `
|
||||
position:fixed;bottom:0;left:0;right:0;z-index:100;
|
||||
padding:12px 16px;padding-bottom:calc(12px + env(safe-area-inset-bottom));
|
||||
background:var(--bg);border-top:1px solid var(--border);
|
||||
display:flex;gap:8px;
|
||||
`;
|
||||
bottomNav.innerHTML = `
|
||||
<button class="btn-secondary" id="sm-back-btn" style="flex:1;">← Назад</button>
|
||||
<button class="btn-primary" id="sm-next-btn" style="flex:2;">Далее →</button>
|
||||
`;
|
||||
container.appendChild(bottomNav);
|
||||
|
||||
const errorDiv = document.createElement("div");
|
||||
errorDiv.style.cssText = "color:#C0392B;font-size:13px;text-align:center;padding:4px 16px;min-height:20px;";
|
||||
container.insertBefore(errorDiv, bottomNav);
|
||||
|
||||
function renderCurrentStep() {
|
||||
screen.innerHTML = "";
|
||||
errorDiv.textContent = "";
|
||||
|
||||
// Update header
|
||||
const titleEl = container.querySelector("#sm-header-title");
|
||||
if (titleEl) titleEl.textContent = `Шаг ${state.step} / 5 — ${STEP_TITLES[state.step - 1]}`;
|
||||
|
||||
// Progress
|
||||
progressFill.style.width = `${(state.step / 5) * 100}%`;
|
||||
|
||||
// Back button
|
||||
const backBtn = container.querySelector("#sm-back-btn");
|
||||
const nextBtn = container.querySelector("#sm-next-btn");
|
||||
|
||||
if (state.step === 5) {
|
||||
nextBtn.textContent = "Отправить замер";
|
||||
} else {
|
||||
nextBtn.textContent = "Далее →";
|
||||
}
|
||||
|
||||
backBtn.style.display = state.step === 1 ? "none" : "";
|
||||
|
||||
// Render step content
|
||||
let stepEl;
|
||||
if (state.step === 1) stepEl = renderStep1(state);
|
||||
else if (state.step === 2) stepEl = renderStep2(state);
|
||||
else if (state.step === 3) stepEl = renderStep3(state);
|
||||
else if (state.step === 4) stepEl = renderStep4();
|
||||
else if (state.step === 5) stepEl = renderStep5(state);
|
||||
if (stepEl) screen.appendChild(stepEl);
|
||||
}
|
||||
|
||||
// Next button handler
|
||||
container.querySelector("#sm-next-btn").addEventListener("click", async () => {
|
||||
haptic && haptic("impact");
|
||||
errorDiv.textContent = "";
|
||||
|
||||
if (!validateStep(state.step, state)) {
|
||||
errorDiv.textContent = getValidationMessage(state.step, state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.step === 5) {
|
||||
await doSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
state.step++;
|
||||
renderCurrentStep();
|
||||
});
|
||||
|
||||
// Back button handler
|
||||
container.querySelector("#sm-back-btn").addEventListener("click", () => {
|
||||
haptic && haptic("impact");
|
||||
if (state.step > 1) {
|
||||
state.step--;
|
||||
renderCurrentStep();
|
||||
}
|
||||
});
|
||||
|
||||
async function doSubmit() {
|
||||
const nextBtn = container.querySelector("#sm-next-btn");
|
||||
nextBtn.disabled = true;
|
||||
nextBtn.textContent = "Отправляем…";
|
||||
errorDiv.textContent = "";
|
||||
|
||||
const payload = {
|
||||
kitchen_type: state.kitchenType,
|
||||
walls: state.walls,
|
||||
comms_skipped: state.commsSkipped,
|
||||
comms_skip_confirmed: state.commsSkipConfirmed,
|
||||
communications: state.commsSkipped ? null : state.comms,
|
||||
client_name: state.contact.name,
|
||||
client_phone: state.contact.phone,
|
||||
address: state.contact.address,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await _api("self_measure_submit", payload);
|
||||
if (res.error) throw new Error(res.error);
|
||||
|
||||
// Success screen
|
||||
screen.innerHTML = "";
|
||||
container.querySelector("#sm-back-btn").style.display = "none";
|
||||
nextBtn.style.display = "none";
|
||||
errorDiv.textContent = "";
|
||||
|
||||
const success = document.createElement("div");
|
||||
success.style.cssText = "text-align:center;padding:40px 24px;";
|
||||
success.innerHTML = `
|
||||
<div style="font-size:56px;margin-bottom:16px;">✅</div>
|
||||
<div style="font-size:20px;font-weight:700;color:var(--ink);margin-bottom:10px;">Замер отправлен!</div>
|
||||
<div style="font-size:14px;color:var(--muted);line-height:1.6;margin-bottom:24px;">
|
||||
Менеджер свяжется с вами в ближайшее время.
|
||||
</div>
|
||||
<button class="btn-secondary" id="sm-done-btn">← В кабинет</button>
|
||||
`;
|
||||
success.querySelector("#sm-done-btn").addEventListener("click", () => {
|
||||
haptic && haptic("success");
|
||||
location.hash = "#/c/cabinet";
|
||||
});
|
||||
screen.appendChild(success);
|
||||
haptic && haptic("success");
|
||||
} catch (e) {
|
||||
errorDiv.textContent = "Ошибка: " + e.message;
|
||||
nextBtn.disabled = false;
|
||||
nextBtn.textContent = "Отправить замер";
|
||||
}
|
||||
}
|
||||
|
||||
renderCurrentStep();
|
||||
}
|
||||
|
||||
return { mount };
|
||||
})();
|
||||
@ -48,6 +48,7 @@
|
||||
<script src="assets/me.js?v=20260518h"></script>
|
||||
<script src="assets/inbox.js?v=20260518i"></script>
|
||||
<script src="assets/cabinet.js?v=20260518j"></script>
|
||||
<script src="assets/app.js?v=20260518j"></script>
|
||||
<script src="assets/selfmeasure.js?v=20260518k"></script>
|
||||
<script src="assets/app.js?v=20260518k"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user