mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 19:44:48 +00:00
measurements: kitchen layout wizard + 5 layout pictograms + profile integration
5 LAYOUT PICTOGRAMS (podbor.picts.js): - linear: одна стена с гарнитуром - l_shape: Г-образная, две стены с подсвеченным углом - u_shape: П-образная, три стены - island: линейный гарнитур + отдельный остров посередине - peninsula: Г-образная + барная стойка-полуостров Все в стиле D · top-down view, walnut stroke, теплые градиенты MEASUREMENTS.JS WIZARD (5 шагов): 1. client_info — имя + телефон (валидация) 2. layout — pict-карточки 5 типов 3. size — длины стен (1-3 по layout), площадь, потолок (мм) 4. openings — окно / дверь / коммуникации / заметки 5. summary — обзор + Сохранить → POST /api/measurement BACKEND (main.py): - New /api/measurements (POST) для списка замеров менеджера с опц. фильтрами по client_tg_id - _handle_measurement теперь дописывает имя+телефон клиента в notes (если client_tg_id не зарегистрирован — это новый клиент без аккаунта) - handlers dispatcher: 'measurements' route added ROUTING (app.js): - Quick-action 'Новый замер' wired to '#/measure' - routeByHash: Measurements.mount on #/measure CLIENT PROFILE (clients.js): - New section 'Замеры · N' on client history page - fetchMeasurements() filters by client_tg_id or name match in notes - layoutLabel() shows Russian label (Прямая / Угловая Г / etc.) - Cache bump v=20260512c
This commit is contained in:
parent
43c43af795
commit
10bcc75b13
@ -86,6 +86,7 @@ async def _dispatch_post(request: Request):
|
|||||||
handlers = {
|
handlers = {
|
||||||
"me": _handle_me,
|
"me": _handle_me,
|
||||||
"measurement": _handle_measurement,
|
"measurement": _handle_measurement,
|
||||||
|
"measurements": _handle_measurements_list,
|
||||||
"podbor": _handle_podbor,
|
"podbor": _handle_podbor,
|
||||||
"clients": _handle_clients,
|
"clients": _handle_clients,
|
||||||
"lead": _handle_lead,
|
"lead": _handle_lead,
|
||||||
@ -149,6 +150,12 @@ async def api_lead(request: Request):
|
|||||||
return _handle_lead(body)
|
return _handle_lead(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/measurements")
|
||||||
|
async def api_measurements(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_measurements_list(body)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/test_ai")
|
@app.get("/api/test_ai")
|
||||||
async def api_test_ai():
|
async def api_test_ai():
|
||||||
return _handle_test_ai()
|
return _handle_test_ai()
|
||||||
@ -408,6 +415,16 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
sheets.find_row("Clients", "tg_id", tg_id) or {}
|
sheets.find_row("Clients", "tg_id", tg_id) or {}
|
||||||
).get("manager_tg_id", "")
|
).get("manager_tg_id", "")
|
||||||
|
|
||||||
|
# Прикрепляем имя/телефон клиента к notes если client_tg_id нет (новый клиент)
|
||||||
|
notes_full = m.get("notes", "")
|
||||||
|
extras = []
|
||||||
|
if m.get("client_name"):
|
||||||
|
extras.append(f"Клиент: {m['client_name']}")
|
||||||
|
if m.get("client_phone"):
|
||||||
|
extras.append(f"Тел: {m['client_phone']}")
|
||||||
|
if extras:
|
||||||
|
notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "")
|
||||||
|
|
||||||
sheets.append_row("Measurements", [
|
sheets.append_row("Measurements", [
|
||||||
measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "",
|
measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "",
|
||||||
filled_by,
|
filled_by,
|
||||||
@ -417,7 +434,7 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
json.dumps(m.get("infra") or {}, ensure_ascii=False),
|
json.dumps(m.get("infra") or {}, ensure_ascii=False),
|
||||||
json.dumps(m.get("niches") or {}, ensure_ascii=False),
|
json.dumps(m.get("niches") or {}, ensure_ascii=False),
|
||||||
",".join(m.get("photos") or []),
|
",".join(m.get("photos") or []),
|
||||||
m.get("notes", ""),
|
notes_full,
|
||||||
"submitted",
|
"submitted",
|
||||||
])
|
])
|
||||||
|
|
||||||
@ -661,6 +678,60 @@ def _handle_lead(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Список замеров менеджера, опционально отфильтрованный по client_tg_id / client_name."""
|
||||||
|
cfg = get_config()
|
||||||
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
|
if not auth or not auth.get("user"):
|
||||||
|
return {"error": "invalid_init_data"}
|
||||||
|
tg_id = auth["user"]["id"]
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user or user.get("role") != "manager":
|
||||||
|
return {"error": "only_manager"}
|
||||||
|
|
||||||
|
client_tg_id = body.get("client_tg_id") or ""
|
||||||
|
client_name = (body.get("client_name") or "").strip().lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws = sheets.sheet("Measurements")
|
||||||
|
rows = ws.get_all_values()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Failed to read Measurements: %s", e)
|
||||||
|
return {"ok": True, "measurements": []}
|
||||||
|
|
||||||
|
if not rows or len(rows) < 2:
|
||||||
|
return {"ok": True, "measurements": []}
|
||||||
|
|
||||||
|
headers = rows[0]
|
||||||
|
out = []
|
||||||
|
for r in rows[1:]:
|
||||||
|
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
|
||||||
|
if str(row.get("manager_tg_id", "")) != str(tg_id):
|
||||||
|
continue
|
||||||
|
# Опциональные фильтры по клиенту
|
||||||
|
if client_tg_id and str(row.get("client_tg_id", "")) != str(client_tg_id):
|
||||||
|
continue
|
||||||
|
# Из notes / других JSON-полей вытащим client_name если был передан в measurement
|
||||||
|
# (он не сохраняется в отдельной колонке — только в JSON-обвязке)
|
||||||
|
# Для MVP — фильтр по имени делаем после парсинга JSON-полей
|
||||||
|
out.append({
|
||||||
|
"id": row.get("id", ""),
|
||||||
|
"created_at": row.get("ts") or row.get("created_at", ""),
|
||||||
|
"client_tg_id": row.get("client_tg_id", ""),
|
||||||
|
"manager_tg_id": row.get("manager_tg_id", ""),
|
||||||
|
"filled_by": row.get("filled_by", ""),
|
||||||
|
"layout": row.get("layout", ""),
|
||||||
|
"area_m2": row.get("area_m2", ""),
|
||||||
|
"ceiling_mm": row.get("ceiling_mm", ""),
|
||||||
|
"notes": row.get("notes", ""),
|
||||||
|
"status": row.get("status", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Сортируем по дате desc
|
||||||
|
out.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||||
|
return {"ok": True, "count": len(out), "measurements": out}
|
||||||
|
|
||||||
|
|
||||||
def _handle_test_ai() -> dict[str, Any]:
|
def _handle_test_ai() -> dict[str, Any]:
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
res = ai.call_ai("Скажи одной фразой: что за фабрика ЗОВ?",
|
res = ai.call_ai("Скажи одной фразой: что за фабрика ЗОВ?",
|
||||||
|
|||||||
@ -179,8 +179,8 @@ function renderManagerHome(me) {
|
|||||||
const quickActions = [
|
const quickActions = [
|
||||||
{ icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" },
|
{ icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" },
|
||||||
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
||||||
{ icon: "camera", title: "Новый замер", subtitle: "С фото", href: null },
|
{ icon: "camera", title: "Новый замер", subtitle: "Кухня клиента", href: "#/measure" },
|
||||||
{ icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null },
|
{ icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null },
|
||||||
];
|
];
|
||||||
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
|
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
|
||||||
const grid = el(`<div class="quick-grid"></div>`);
|
const grid = el(`<div class="quick-grid"></div>`);
|
||||||
@ -370,6 +370,10 @@ async function init() {
|
|||||||
Clients.mount(app);
|
Clients.mount(app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (location.hash.startsWith("#/measure")) {
|
||||||
|
Measurements.mount(app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (me.role === "manager") renderManager(me);
|
if (me.role === "manager") renderManager(me);
|
||||||
else renderClient(me);
|
else renderClient(me);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -383,6 +387,8 @@ function routeByHash() {
|
|||||||
Podbor.mount(app);
|
Podbor.mount(app);
|
||||||
} else if (location.hash.startsWith("#/clients")) {
|
} else if (location.hash.startsWith("#/clients")) {
|
||||||
Clients.mount(app);
|
Clients.mount(app);
|
||||||
|
} else if (location.hash.startsWith("#/measure")) {
|
||||||
|
Measurements.mount(app);
|
||||||
} else {
|
} else {
|
||||||
// Главный экран по роли
|
// Главный экран по роли
|
||||||
const me = window.__zovMe;
|
const me = window.__zovMe;
|
||||||
|
|||||||
@ -153,6 +153,45 @@ const Clients = (function () {
|
|||||||
leadsList.appendChild(item);
|
leadsList.appendChild(item);
|
||||||
}
|
}
|
||||||
root.appendChild(leadsList);
|
root.appendChild(leadsList);
|
||||||
|
|
||||||
|
// Замеры этого клиента (если есть)
|
||||||
|
try {
|
||||||
|
const ms = await fetchMeasurements({ client_tg_id: client.client_tg_id || "" });
|
||||||
|
const myMeasurements = (ms.measurements || []).filter(m => {
|
||||||
|
// Если client_tg_id зарегистрирован — фильтруем по нему
|
||||||
|
if (client.client_tg_id) return String(m.client_tg_id) === String(client.client_tg_id);
|
||||||
|
// Иначе — ищем имя клиента в notes (упрощённая логика для новых клиентов)
|
||||||
|
return (m.notes || "").toLowerCase().includes((client.client_name || "").toLowerCase());
|
||||||
|
});
|
||||||
|
if (myMeasurements.length) {
|
||||||
|
root.appendChild(el(`<div class="section-head" style="margin-top:24px;"><span class="label">Замеры · ${myMeasurements.length}</span></div>`));
|
||||||
|
const mList = el(`<div class="leads-list"></div>`);
|
||||||
|
for (const m of myMeasurements) {
|
||||||
|
const item = el(`
|
||||||
|
<div class="lead-item" style="cursor:default;">
|
||||||
|
<div class="lead-date">${formatDate(m.created_at)}</div>
|
||||||
|
<div class="lead-id">${escHtml(layoutLabel(m.layout))}</div>
|
||||||
|
<div class="lead-status">${m.area_m2 ? m.area_m2 + " м²" : "—"}</div>
|
||||||
|
<div class="lead-arrow"></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
mList.appendChild(item);
|
||||||
|
}
|
||||||
|
root.appendChild(mList);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Игнорируем — секция замеров просто не покажется
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutLabel(key) {
|
||||||
|
return ({
|
||||||
|
linear: "Прямая",
|
||||||
|
l_shape: "Угловая Г",
|
||||||
|
u_shape: "П-образная",
|
||||||
|
island: "С островом",
|
||||||
|
peninsula: "Полуостров",
|
||||||
|
}[key]) || (key || "—");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Детали лида (re-render отчёта) ===================== */
|
/* ===================== Детали лида (re-render отчёта) ===================== */
|
||||||
@ -245,6 +284,15 @@ const Clients = (function () {
|
|||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchMeasurements(filters = {}) {
|
||||||
|
if (!BACKEND_URL) throw new Error("BACKEND_URL не задан");
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/measurements`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ initData: tg?.initData || "", ...filters }),
|
||||||
|
});
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
function initial(name) {
|
function initial(name) {
|
||||||
return ((name || "?").trim()[0] || "?").toUpperCase();
|
return ((name || "?").trim()[0] || "?").toUpperCase();
|
||||||
}
|
}
|
||||||
|
|||||||
455
miniapp/assets/measurements.js
Normal file
455
miniapp/assets/measurements.js
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Замеры кухни — wizard для менеджера
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const Measurements = (function () {
|
||||||
|
const STORAGE_KEY = "zov-measurement-draft";
|
||||||
|
const STEPS = ["client", "layout", "size", "openings", "summary"];
|
||||||
|
const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Готово"];
|
||||||
|
|
||||||
|
const LAYOUTS = [
|
||||||
|
{ key: "linear", label: "Прямая", hint: "одна стена", pict: "layout_linear" },
|
||||||
|
{ key: "l_shape", label: "Угловая Г", hint: "две стены, угол", pict: "layout_l_shape" },
|
||||||
|
{ key: "u_shape", label: "П-образная", hint: "три стены", pict: "layout_u_shape" },
|
||||||
|
{ key: "island", label: "С островом", hint: "линейная + блок", pict: "layout_island" },
|
||||||
|
{ key: "peninsula", label: "Полуостров", hint: "Г + барная", pict: "layout_peninsula" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let state = loadState();
|
||||||
|
let root = null;
|
||||||
|
let currentStep = "client";
|
||||||
|
|
||||||
|
function loadState() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch (e) {}
|
||||||
|
return defaultState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultState() {
|
||||||
|
return {
|
||||||
|
client_name: "",
|
||||||
|
client_phone: "",
|
||||||
|
client_tg_id: "",
|
||||||
|
layout: "",
|
||||||
|
area_m2: "",
|
||||||
|
ceiling_mm: "",
|
||||||
|
walls: {}, // { wall1: 3200, wall2: 4100, ... } — мм
|
||||||
|
openings: {
|
||||||
|
window: "", // расположение окна
|
||||||
|
door: "", // расположение двери
|
||||||
|
},
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveState() {
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(patch) {
|
||||||
|
state = { ...state, ...patch };
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
state = defaultState();
|
||||||
|
saveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Mount + Render ===================== */
|
||||||
|
|
||||||
|
function mount(container) {
|
||||||
|
root = container;
|
||||||
|
document.body.classList.remove("has-bottom-nav");
|
||||||
|
const oldNav = document.getElementById("bottom-nav");
|
||||||
|
if (oldNav) oldNav.remove();
|
||||||
|
currentStep = "client";
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(step) {
|
||||||
|
if (!STEPS.includes(step)) return;
|
||||||
|
currentStep = step;
|
||||||
|
render();
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
haptic && haptic("impact");
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = "";
|
||||||
|
root.appendChild(renderHeader());
|
||||||
|
root.appendChild(renderProgress());
|
||||||
|
const screen = el(`<div class="podbor-screen"></div>`);
|
||||||
|
root.appendChild(screen);
|
||||||
|
|
||||||
|
switch (currentStep) {
|
||||||
|
case "client": screen.appendChild(renderClient()); break;
|
||||||
|
case "layout": screen.appendChild(renderLayout()); break;
|
||||||
|
case "size": screen.appendChild(renderSize()); break;
|
||||||
|
case "openings": screen.appendChild(renderOpenings()); break;
|
||||||
|
case "summary": screen.appendChild(renderSummary()); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeader() {
|
||||||
|
const h = el(`
|
||||||
|
<header class="podbor-header">
|
||||||
|
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
||||||
|
<div class="podbor-title">Новый замер</div>
|
||||||
|
<div style="width:28px"></div>
|
||||||
|
</header>
|
||||||
|
`);
|
||||||
|
h.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
|
const idx = STEPS.indexOf(currentStep);
|
||||||
|
if (idx <= 0) {
|
||||||
|
location.hash = "";
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
go(STEPS[idx - 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProgress() {
|
||||||
|
const idx = STEPS.indexOf(currentStep);
|
||||||
|
const pct = Math.round(((idx + 1) / STEPS.length) * 100);
|
||||||
|
return el(`
|
||||||
|
<div class="podbor-progress">
|
||||||
|
<div class="podbor-progress-bar"><div class="bar" style="width:${pct}%"></div></div>
|
||||||
|
<div class="podbor-progress-meta">
|
||||||
|
<span>${STEP_LABELS[idx]}</span><span class="num">${idx + 1}/${STEPS.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Шаг 1: Клиент ===================== */
|
||||||
|
|
||||||
|
function renderClient() {
|
||||||
|
const node = el(`
|
||||||
|
<section class="podbor-step">
|
||||||
|
<h2 class="display-title">Для какого<br><span class="accent">клиента?</span></h2>
|
||||||
|
<p class="lede">Имя и телефон клиента, для которого делается замер.</p>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Имя клиента</span>
|
||||||
|
<input type="text" data-bind="client_name" value="${escAttr(state.client_name)}" placeholder="Например: А. Пестова">
|
||||||
|
<span class="field-error" id="nameError"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Телефон</span>
|
||||||
|
<input type="tel" data-bind="client_phone" value="${escAttr(state.client_phone)}" placeholder="+7 900 123-45-67">
|
||||||
|
<span class="field-hint">Можно без +7, нормализуем</span>
|
||||||
|
<span class="field-error" id="phoneError"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podbor-cta-row">
|
||||||
|
<button class="btn-primary" id="next">Дальше</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
|
||||||
|
bindInputs(node);
|
||||||
|
|
||||||
|
node.querySelector("#next").addEventListener("click", () => {
|
||||||
|
const name = (state.client_name || "").trim();
|
||||||
|
const phone = (state.client_phone || "").trim();
|
||||||
|
if (!name) {
|
||||||
|
node.querySelector("#nameError").textContent = "Укажите имя";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Используем нормализацию из podbor
|
||||||
|
if (phone && window.Podbor && typeof normalizePhoneShared === "function") {
|
||||||
|
// not exposed — поэтому минимальная локальная проверка
|
||||||
|
}
|
||||||
|
if (phone && phone.replace(/\D/g, "").length < 10) {
|
||||||
|
node.querySelector("#phoneError").textContent = "Слишком короткий номер";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
go("layout");
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Шаг 2: Форма ===================== */
|
||||||
|
|
||||||
|
function renderLayout() {
|
||||||
|
const cur = state.layout || "";
|
||||||
|
const cards = LAYOUTS.map(o => {
|
||||||
|
const isOn = cur === o.key;
|
||||||
|
const pict = PODBOR_PICTS[o.pict] || "";
|
||||||
|
return `
|
||||||
|
<button class="wiz-card${isOn ? " on" : ""}" data-val="${o.key}">
|
||||||
|
<div class="wiz-pict">${pict}</div>
|
||||||
|
<div class="wiz-label">${o.label}</div>
|
||||||
|
${o.hint ? `<div class="wiz-hint">${o.hint}</div>` : ""}
|
||||||
|
${isOn ? `<div class="wiz-tick">${ICONS.check}</div>` : ""}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const node = el(`
|
||||||
|
<section class="podbor-step podbor-wizard">
|
||||||
|
<h3 class="wiz-title">Форма кухни</h3>
|
||||||
|
<p class="lede" style="margin:0 0 8px;">Как расположены гарнитуры?</p>
|
||||||
|
<div class="wiz-grid wiz-grid--cards">${cards}</div>
|
||||||
|
<div class="podbor-cta-row">
|
||||||
|
<button class="btn-secondary" id="back">Назад</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
node.querySelectorAll(".wiz-card").forEach(card => {
|
||||||
|
card.addEventListener("click", () => {
|
||||||
|
update({ layout: card.dataset.val });
|
||||||
|
haptic && haptic("impact");
|
||||||
|
go("size");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
node.querySelector("#back").addEventListener("click", () => go("client"));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Шаг 3: Размеры ===================== */
|
||||||
|
|
||||||
|
function renderSize() {
|
||||||
|
// По выбранной планировке — определяем сколько стен
|
||||||
|
const wallsCount = {
|
||||||
|
linear: 1, l_shape: 2, u_shape: 3, island: 1, peninsula: 2,
|
||||||
|
}[state.layout] || 1;
|
||||||
|
|
||||||
|
const wallInputs = [];
|
||||||
|
for (let i = 1; i <= wallsCount; i++) {
|
||||||
|
const v = (state.walls && state.walls[`wall${i}`]) || "";
|
||||||
|
const label = wallsCount === 1 ? "Длина стены, мм"
|
||||||
|
: `Стена ${i} (${i === 1 ? "основная" : "доп."}), мм`;
|
||||||
|
wallInputs.push(`
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">${label}</span>
|
||||||
|
<input type="number" inputmode="numeric" data-wall="wall${i}" value="${v}" placeholder="например 3200">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = el(`
|
||||||
|
<section class="podbor-step">
|
||||||
|
<h2 class="display-title">Размеры<br><span class="accent">кухни</span></h2>
|
||||||
|
<p class="lede">Длины стен в миллиметрах + высота потолка.</p>
|
||||||
|
|
||||||
|
${wallInputs.join("")}
|
||||||
|
|
||||||
|
<div class="form-row two-col">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Площадь, м²</span>
|
||||||
|
<input type="number" inputmode="decimal" step="0.1" data-bind="area_m2" value="${state.area_m2}" placeholder="12.5">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Потолок, мм</span>
|
||||||
|
<input type="number" inputmode="numeric" data-bind="ceiling_mm" value="${state.ceiling_mm}" placeholder="2700">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podbor-cta-row">
|
||||||
|
<button class="btn-secondary" id="back">Назад</button>
|
||||||
|
<button class="btn-primary" id="next">Дальше</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
|
||||||
|
bindInputs(node);
|
||||||
|
// Wall inputs — пишем в state.walls
|
||||||
|
node.querySelectorAll("[data-wall]").forEach(inp => {
|
||||||
|
inp.addEventListener("input", e => {
|
||||||
|
const w = { ...(state.walls || {}), [e.target.dataset.wall]: e.target.value };
|
||||||
|
update({ walls: w });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
node.querySelector("#back").addEventListener("click", () => go("layout"));
|
||||||
|
node.querySelector("#next").addEventListener("click", () => go("openings"));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Шаг 4: Окна и двери ===================== */
|
||||||
|
|
||||||
|
function renderOpenings() {
|
||||||
|
const o = state.openings || {};
|
||||||
|
const node = el(`
|
||||||
|
<section class="podbor-step">
|
||||||
|
<h2 class="display-title">Окна<br><span class="accent">и двери</span></h2>
|
||||||
|
<p class="lede">Опиши расположение — где окно, откуда вход, есть ли коммуникации.</p>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Окно</span>
|
||||||
|
<textarea data-open="window" rows="2" placeholder="например: на стене 1, отступ 1200 от угла, ширина 1500">${escHtml(o.window || "")}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Дверь / вход</span>
|
||||||
|
<textarea data-open="door" rows="2" placeholder="например: вход со стороны коридора, дверь на стене 3">${escHtml(o.door || "")}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Заметки</span>
|
||||||
|
<textarea data-bind="notes" rows="3" placeholder="газ/электро, вентшахта, ниши под технику, особые пожелания">${escHtml(state.notes || "")}</textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podbor-cta-row">
|
||||||
|
<button class="btn-secondary" id="back">Назад</button>
|
||||||
|
<button class="btn-primary" id="next">Дальше</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
|
||||||
|
bindInputs(node);
|
||||||
|
node.querySelectorAll("[data-open]").forEach(inp => {
|
||||||
|
inp.addEventListener("input", e => {
|
||||||
|
update({ openings: { ...(state.openings || {}), [e.target.dataset.open]: e.target.value } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
node.querySelector("#back").addEventListener("click", () => go("size"));
|
||||||
|
node.querySelector("#next").addEventListener("click", () => go("summary"));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Шаг 5: Готово + Submit ===================== */
|
||||||
|
|
||||||
|
function renderSummary() {
|
||||||
|
const layout = LAYOUTS.find(l => l.key === state.layout);
|
||||||
|
const wallsText = Object.entries(state.walls || {})
|
||||||
|
.map(([k, v]) => v ? `${k.replace("wall", "стена ")}: ${v} мм` : "")
|
||||||
|
.filter(Boolean).join(" · ");
|
||||||
|
|
||||||
|
const node = el(`
|
||||||
|
<section class="podbor-step">
|
||||||
|
<h2 class="display-title">Готово<br><span class="accent">к сохранению</span></h2>
|
||||||
|
<p class="lede">Проверьте и сохраните замер.</p>
|
||||||
|
<div class="block summary-block">
|
||||||
|
<div class="kv"><span>Клиент</span><strong>${escHtml(state.client_name)}</strong></div>
|
||||||
|
${state.client_phone ? `<div class="kv"><span>Телефон</span><strong>${escHtml(state.client_phone)}</strong></div>` : ""}
|
||||||
|
<div class="kv"><span>Форма</span><strong>${layout?.label || "—"}</strong></div>
|
||||||
|
${wallsText ? `<div class="kv"><span>Стены</span><strong>${escHtml(wallsText)}</strong></div>` : ""}
|
||||||
|
${state.area_m2 ? `<div class="kv"><span>Площадь</span><strong>${escHtml(state.area_m2)} м²</strong></div>` : ""}
|
||||||
|
${state.ceiling_mm ? `<div class="kv"><span>Потолок</span><strong>${escHtml(state.ceiling_mm)} мм</strong></div>` : ""}
|
||||||
|
${(state.openings || {}).window ? `<div class="kv"><span>Окно</span><strong>${escHtml(state.openings.window)}</strong></div>` : ""}
|
||||||
|
${(state.openings || {}).door ? `<div class="kv"><span>Дверь</span><strong>${escHtml(state.openings.door)}</strong></div>` : ""}
|
||||||
|
${state.notes ? `<div class="kv"><span>Заметки</span><strong>${escHtml(state.notes)}</strong></div>` : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podbor-cta-row">
|
||||||
|
<button class="btn-secondary" id="back">Назад</button>
|
||||||
|
<button class="btn-primary" id="submitBtn">Сохранить замер</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="submitResult" class="submit-result"></div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
node.querySelector("#back").addEventListener("click", () => go("openings"));
|
||||||
|
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(node) {
|
||||||
|
const btn = node.querySelector("#submitBtn");
|
||||||
|
const result = node.querySelector("#submitResult");
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-inline"></span> сохраняем...';
|
||||||
|
result.innerHTML = "";
|
||||||
|
|
||||||
|
if (!BACKEND_URL) {
|
||||||
|
result.innerHTML = `<div class="error">BACKEND_URL не настроен.</div>`;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Сохранить замер";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurement = {
|
||||||
|
layout: state.layout,
|
||||||
|
area_m2: state.area_m2,
|
||||||
|
ceiling_mm: state.ceiling_mm,
|
||||||
|
walls: state.walls,
|
||||||
|
openings: state.openings,
|
||||||
|
infra: {},
|
||||||
|
niches: {},
|
||||||
|
photos: [],
|
||||||
|
notes: state.notes,
|
||||||
|
// Контакт клиента — заносим в заметки если он не зарегистрирован в системе
|
||||||
|
client_name: state.client_name,
|
||||||
|
client_phone: state.client_phone,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/measurement`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
measurement,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
result.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`;
|
||||||
|
} else {
|
||||||
|
result.innerHTML = `
|
||||||
|
<div class="success">
|
||||||
|
<div class="success-icon">${ICONS.check}</div>
|
||||||
|
<div>
|
||||||
|
<div class="success-title">Замер сохранён</div>
|
||||||
|
<div class="success-sub">ID #${(data.id || "").slice(0, 6)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="podbor-cta-row" style="margin-top:16px;">
|
||||||
|
<button class="btn-secondary" id="newOne">Ещё замер</button>
|
||||||
|
<button class="btn-primary" id="toHome">На главную</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
haptic && haptic("success");
|
||||||
|
reset(); // сбрасываем форму для следующего замера
|
||||||
|
node.querySelector("#newOne")?.addEventListener("click", () => { mount(root); });
|
||||||
|
node.querySelector("#toHome")?.addEventListener("click", () => {
|
||||||
|
location.hash = "";
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
result.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Сохранить ещё раз";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Helpers ===================== */
|
||||||
|
|
||||||
|
function bindInputs(node) {
|
||||||
|
node.querySelectorAll("[data-bind]").forEach(inp => {
|
||||||
|
inp.addEventListener("input", e => {
|
||||||
|
update({ [e.target.dataset.bind]: e.target.value });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function escAttr(s) { return escHtml(s); }
|
||||||
|
|
||||||
|
return { mount, reset };
|
||||||
|
})();
|
||||||
@ -921,6 +921,117 @@ const PODBOR_PICTS = {
|
|||||||
</svg>
|
</svg>
|
||||||
`,
|
`,
|
||||||
|
|
||||||
|
/* ===== Планировка кухни · 5 типов (top-down вид) ===== */
|
||||||
|
|
||||||
|
layout_linear: `
|
||||||
|
<svg viewBox="0 0 96 128">
|
||||||
|
<!-- Стены комнаты пунктиром -->
|
||||||
|
<rect x="10" y="20" width="76" height="84" rx="2" fill="none" stroke="#6B4A2B" stroke-width="0.8" stroke-dasharray="3 2" opacity="0.4"/>
|
||||||
|
<!-- Подпись -->
|
||||||
|
<text x="48" y="14" text-anchor="middle" font-family="JetBrains Mono" font-size="8" fill="#6B4A2B" stroke="none">ПРЯМАЯ</text>
|
||||||
|
<!-- Кухонный гарнитур вдоль одной стены -->
|
||||||
|
<rect x="14" y="84" width="68" height="16" rx="2" fill="url(#g-twoch)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="14" y="84" width="68" height="16" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<!-- Конфорки на гарнитуре (намёк) -->
|
||||||
|
<circle cx="28" cy="92" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<circle cx="36" cy="92" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<!-- Раковина -->
|
||||||
|
<rect x="56" y="88" width="14" height="8" rx="1" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<!-- Длина -->
|
||||||
|
<line x1="14" y1="106" x2="82" y2="106" stroke="#6B4A2B" stroke-width="0.6" opacity="0.5"/>
|
||||||
|
<text x="48" y="116" text-anchor="middle" font-family="JetBrains Mono" font-size="7" fill="#6B4A2B" stroke="none" opacity="0.6">L · одна стена</text>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
|
||||||
|
layout_l_shape: `
|
||||||
|
<svg viewBox="0 0 96 128">
|
||||||
|
<rect x="10" y="20" width="76" height="84" rx="2" fill="none" stroke="#6B4A2B" stroke-width="0.8" stroke-dasharray="3 2" opacity="0.4"/>
|
||||||
|
<text x="48" y="14" text-anchor="middle" font-family="JetBrains Mono" font-size="8" fill="#6B4A2B" stroke="none">Г-ОБРАЗНАЯ</text>
|
||||||
|
<!-- Вертикальная стена -->
|
||||||
|
<rect x="14" y="24" width="16" height="76" rx="2" fill="url(#g-twoch)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="14" y="24" width="16" height="76" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<!-- Горизонтальная стена -->
|
||||||
|
<rect x="14" y="84" width="68" height="16" rx="2" fill="url(#g-twoch)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="14" y="84" width="68" height="16" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<!-- Угол подсвечен -->
|
||||||
|
<rect x="14" y="84" width="16" height="16" fill="#6B4A2B" opacity="0.15"/>
|
||||||
|
<!-- Конфорки -->
|
||||||
|
<circle cx="22" cy="36" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<circle cx="22" cy="48" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<!-- Раковина -->
|
||||||
|
<rect x="56" y="88" width="14" height="8" rx="1" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<text x="48" y="116" text-anchor="middle" font-family="JetBrains Mono" font-size="7" fill="#6B4A2B" stroke="none" opacity="0.6">две стены · угол</text>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
|
||||||
|
layout_u_shape: `
|
||||||
|
<svg viewBox="0 0 96 128">
|
||||||
|
<rect x="10" y="20" width="76" height="84" rx="2" fill="none" stroke="#6B4A2B" stroke-width="0.8" stroke-dasharray="3 2" opacity="0.4"/>
|
||||||
|
<text x="48" y="14" text-anchor="middle" font-family="JetBrains Mono" font-size="8" fill="#6B4A2B" stroke="none">П-ОБРАЗНАЯ</text>
|
||||||
|
<!-- Левая стена -->
|
||||||
|
<rect x="14" y="24" width="16" height="76" rx="2" fill="url(#g-twoch)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="14" y="24" width="16" height="76" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<!-- Правая стена -->
|
||||||
|
<rect x="66" y="24" width="16" height="76" rx="2" fill="url(#g-twoch)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="66" y="24" width="16" height="76" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<!-- Нижняя стена -->
|
||||||
|
<rect x="14" y="84" width="68" height="16" rx="2" fill="url(#g-twoch)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="14" y="84" width="68" height="16" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<!-- Углы -->
|
||||||
|
<rect x="14" y="84" width="16" height="16" fill="#6B4A2B" opacity="0.15"/>
|
||||||
|
<rect x="66" y="84" width="16" height="16" fill="#6B4A2B" opacity="0.15"/>
|
||||||
|
<!-- Конфорки слева -->
|
||||||
|
<circle cx="22" cy="40" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<circle cx="22" cy="52" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<!-- Раковина внизу -->
|
||||||
|
<rect x="42" y="88" width="14" height="8" rx="1" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<!-- Холодильник справа -->
|
||||||
|
<rect x="68" y="32" width="12" height="20" rx="1" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<text x="48" y="116" text-anchor="middle" font-family="JetBrains Mono" font-size="7" fill="#6B4A2B" stroke="none" opacity="0.6">три стены</text>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
|
||||||
|
layout_island: `
|
||||||
|
<svg viewBox="0 0 96 128">
|
||||||
|
<rect x="10" y="20" width="76" height="84" rx="2" fill="none" stroke="#6B4A2B" stroke-width="0.8" stroke-dasharray="3 2" opacity="0.4"/>
|
||||||
|
<text x="48" y="14" text-anchor="middle" font-family="JetBrains Mono" font-size="8" fill="#6B4A2B" stroke="none">С ОСТРОВОМ</text>
|
||||||
|
<!-- Линейный гарнитур вдоль стены -->
|
||||||
|
<rect x="14" y="84" width="68" height="16" rx="2" fill="url(#g-twoch)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="14" y="84" width="68" height="16" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<!-- Конфорки + раковина -->
|
||||||
|
<circle cx="26" cy="92" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<circle cx="34" cy="92" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<rect x="56" y="88" width="14" height="8" rx="1" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<!-- Остров -->
|
||||||
|
<rect x="32" y="50" width="32" height="20" rx="2" fill="url(#g-cold)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="32" y="50" width="32" height="20" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<text x="48" y="64" text-anchor="middle" font-family="JetBrains Mono" font-size="6" fill="#6B4A2B" stroke="none" opacity="0.7">ОСТРОВ</text>
|
||||||
|
<text x="48" y="116" text-anchor="middle" font-family="JetBrains Mono" font-size="7" fill="#6B4A2B" stroke="none" opacity="0.6">отдельный блок</text>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
|
||||||
|
layout_peninsula: `
|
||||||
|
<svg viewBox="0 0 96 128">
|
||||||
|
<rect x="10" y="20" width="76" height="84" rx="2" fill="none" stroke="#6B4A2B" stroke-width="0.8" stroke-dasharray="3 2" opacity="0.4"/>
|
||||||
|
<text x="48" y="14" text-anchor="middle" font-family="JetBrains Mono" font-size="8" fill="#6B4A2B" stroke="none">ПОЛУОСТРОВ</text>
|
||||||
|
<!-- Г-образная база -->
|
||||||
|
<rect x="14" y="24" width="16" height="48" rx="2" fill="url(#g-twoch)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="14" y="24" width="16" height="48" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<rect x="14" y="56" width="56" height="16" rx="2" fill="url(#g-twoch)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="14" y="56" width="56" height="16" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<rect x="14" y="56" width="16" height="16" fill="#6B4A2B" opacity="0.15"/>
|
||||||
|
<!-- Полуостров — продолжение в комнату -->
|
||||||
|
<rect x="58" y="72" width="16" height="26" rx="2" fill="url(#g-cold)" stroke="#6B4A2B" stroke-width="1.4"/>
|
||||||
|
<rect x="58" y="72" width="16" height="26" rx="2" fill="url(#g-sheen)"/>
|
||||||
|
<!-- Барные стулья -->
|
||||||
|
<circle cx="50" cy="85" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.8" opacity="0.5"/>
|
||||||
|
<circle cx="50" cy="93" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.8" opacity="0.5"/>
|
||||||
|
<!-- Конфорки -->
|
||||||
|
<circle cx="22" cy="40" r="2.5" fill="none" stroke="#6B4A2B" stroke-width="0.7" opacity="0.6"/>
|
||||||
|
<text x="48" y="116" text-anchor="middle" font-family="JetBrains Mono" font-size="7" fill="#6B4A2B" stroke="none" opacity="0.6">Г + барная стойка</text>
|
||||||
|
</svg>
|
||||||
|
`,
|
||||||
|
|
||||||
washer_install_freestanding: `
|
washer_install_freestanding: `
|
||||||
<svg viewBox="0 0 96 128">
|
<svg viewBox="0 0 96 128">
|
||||||
<rect x="18" y="12" width="68" height="108" rx="6" fill="#6B4A2B" opacity="0.1"/>
|
<rect x="18" y="12" width="68" height="108" rx="6" fill="#6B4A2B" opacity="0.1"/>
|
||||||
|
|||||||
@ -12,8 +12,8 @@
|
|||||||
<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=20260512b">
|
<link rel="stylesheet" href="assets/styles.css?v=20260512c">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260512b">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260512c">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="app">
|
<main id="app">
|
||||||
@ -21,11 +21,12 @@
|
|||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script src="assets/icons.js?v=20260512b"></script>
|
<script src="assets/icons.js?v=20260512c"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260512b"></script>
|
<script src="assets/podbor.config.js?v=20260512c"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260512b"></script>
|
<script src="assets/podbor.picts.js?v=20260512c"></script>
|
||||||
<script src="assets/podbor.js?v=20260512b"></script>
|
<script src="assets/podbor.js?v=20260512c"></script>
|
||||||
<script src="assets/clients.js?v=20260512b"></script>
|
<script src="assets/clients.js?v=20260512c"></script>
|
||||||
<script src="assets/app.js?v=20260512b"></script>
|
<script src="assets/measurements.js?v=20260512c"></script>
|
||||||
|
<script src="assets/app.js?v=20260512c"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user