/* ============================================================ Подбор техники — render, state, navigation, submit ============================================================ */ const Podbor = (function () { const STORAGE_KEY = "zov-podbor-v3"; const STEPS = ["intro", "categories", "detail", "pricing", "infra", "priorities", "brands", "summary"]; // Внутренний sub-state для шага «detail»: 'menu' | 'cat:' let detailView = "menu"; let state = loadState(); let root = null; let currentStep = "intro"; 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: "", address: "", categories: [], // ['fridge','hob',...] per_cat: {}, // { fridge: { params: {type:'sbs',...}, features: ['nofrost'], notes: '' }, ... } price_ranges: {}, // { fridge: { from: 50000, to: 120000 }, ... } infra: { stove: "", vent: "" }, priorities: [], // ['balance','reviews',...] brands: {}, // { fridge: {Bosch:'preferred',...}, ... } notes: "", }; } function saveState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} } function update(patch) { state = { ...state, ...patch }; saveState(); } /* ===================== Render entry ===================== */ function mount(container) { root = container; document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); render(); } function go(step) { if (!STEPS.includes(step)) return; currentStep = step; detailView = "menu"; // на любой переход detail возвращается в меню 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(`
`); root.appendChild(screen); switch (currentStep) { case "intro": screen.appendChild(renderIntro()); break; case "categories": screen.appendChild(renderCategories()); break; case "detail": screen.appendChild(renderDetail()); break; case "pricing": screen.appendChild(renderPricing()); break; case "infra": screen.appendChild(renderInfra()); break; case "priorities": screen.appendChild(renderPriorities()); break; case "brands": screen.appendChild(renderBrands()); break; case "summary": screen.appendChild(renderSummary()); break; } } /* ===================== Header & progress ===================== */ function renderHeader() { const h = el(`
Подбор техники
`); 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 total = STEPS.length; const pct = Math.round(((idx + 1) / total) * 100); const labels = ["Старт", "Категории", "Параметры", "Цена", "Инфра", "Приоритеты", "Бренды", "Подбор"]; return el(`
${labels[idx]}${idx + 1}/${total}
`); } /* ===================== Step: intro ===================== */ function renderIntro() { const node = el(`

Подбор техники
для клиента

7 коротких шагов. Категории, ценовой коридор, инфраструктура и предпочтения. AI соберёт предложение.

`); bindInputs(node); bindNav(node); return node; } /* ===================== Step: categories ===================== */ function renderCategories() { const grid = PODBOR_CATEGORIES.map(c => ` `).join(""); const node = el(`

Какую технику
подбираем?

Выберите все категории, что нужно подобрать клиенту.

${grid}
`); node.querySelectorAll(".cat-card").forEach(card => { card.addEventListener("click", () => { const cat = card.dataset.cat; const next = state.categories.includes(cat) ? state.categories.filter(x => x !== cat) : [...state.categories, cat]; update({ categories: next }); render(); }); }); bindNav(node); return node; } /* ===================== Step: detail — menu + per-category sub-screen ===================== */ function isCategoryFilled(catKey) { const cat = state.per_cat[catKey]; if (!cat || !cat.params) return false; const params = PODBOR_PARAMS[catKey]?.primary || []; return params.every(p => cat.params[p.key]); } function renderDetail() { if (!state.categories.length) { return el(`
Сначала выберите категории.
`); } if (detailView !== "menu" && detailView.startsWith("cat:")) { const catKey = detailView.slice(4); return renderCategoryDetail(catKey); } return renderDetailMenu(); } function renderDetailMenu() { const cards = state.categories.map(catKey => { const cat = PODBOR_CATEGORIES.find(x => x.key === catKey); const filled = isCategoryFilled(catKey); const summary = filled ? buildPerCatSummary(catKey) : "Заполнить параметры"; return ` `; }).join(""); const node = el(`

Параметры
по категориям

Только главное: тип, размер, цвет. Технические фичи — в «Подробнее ↓», по желанию.

${cards}
`); node.querySelectorAll(".detail-card").forEach(c => { c.addEventListener("click", () => { detailView = "cat:" + c.dataset.cat; render(); }); }); bindNav(node); return node; } function buildPerCatSummary(catKey) { const cat = state.per_cat[catKey]; if (!cat || !cat.params) return "—"; const params = PODBOR_PARAMS[catKey]?.primary || []; const labels = params .map(p => { const opt = p.options.find(o => o.key === cat.params[p.key]); return opt ? opt.label : null; }) .filter(Boolean); return labels.join(" · ") || "—"; } function renderCategoryDetail(catKey) { const cat = PODBOR_CATEGORIES.find(x => x.key === catKey); const config = PODBOR_PARAMS[catKey]; if (!config) { return el(`
Параметры для «${cat?.label}» ещё не описаны.
`); } const catState = state.per_cat[catKey] || { params: {}, features: [], notes: "" }; const isExpanded = catState._expanded || false; const primaryHtml = config.primary.map(p => { const cur = catState.params?.[p.key] || ""; return `
${p.label}
${p.options.map(o => ` `).join("")}
`; }).join(""); const featuresHtml = config.features.map(f => { const on = (catState.features || []).includes(f.key); return ` `; }).join(""); const node = el(`
${ICONS[cat.icon] || ""}

${cat.label}

Главное
${primaryHtml}
Технические фичи — необязательно. Если не отметите, AI выберет сам и пояснит в подборе.
${featuresHtml}
`); // Главное — radio node.querySelectorAll("[data-param]").forEach(b => { b.addEventListener("click", () => { const cs = state.per_cat[catKey] || { params: {}, features: [], notes: "" }; cs.params = { ...(cs.params || {}), [b.dataset.param]: b.dataset.val }; update({ per_cat: { ...state.per_cat, [catKey]: cs } }); render(); }); }); // Features — toggle node.querySelectorAll("[data-feat]").forEach(b => { b.addEventListener("click", () => { const cs = state.per_cat[catKey] || { params: {}, features: [], notes: "" }; const cur = cs.features || []; cs.features = cur.includes(b.dataset.feat) ? cur.filter(x => x !== b.dataset.feat) : [...cur, b.dataset.feat]; update({ per_cat: { ...state.per_cat, [catKey]: cs } }); render(); }); }); // Accordion node.querySelector("[data-toggle='exp']").addEventListener("click", () => { const cs = state.per_cat[catKey] || { params: {}, features: [], notes: "" }; cs._expanded = !cs._expanded; update({ per_cat: { ...state.per_cat, [catKey]: cs } }); render(); }); // Notes const ta = node.querySelector("textarea[data-bind='cat_notes']"); if (ta) ta.addEventListener("input", e => { const cs = state.per_cat[catKey] || { params: {}, features: [], notes: "" }; cs.notes = e.target.value; update({ per_cat: { ...state.per_cat, [catKey]: cs } }); }); // Back / save → menu node.querySelector(".podbor-back").addEventListener("click", () => { detailView = "menu"; render(); }); node.querySelector("#catBack").addEventListener("click", () => { detailView = "menu"; render(); }); node.querySelector("#catSave").addEventListener("click", () => { detailView = "menu"; render(); haptic && haptic("success"); }); return node; } /* ===================== Step: pricing (ценовой коридор по категориям) ===================== */ function renderPricing() { if (!state.categories.length) { return el(`
Сначала выберите категории.
`); } // Подсчёт суммы коридоров let totalFrom = 0, totalTo = 0; state.categories.forEach(c => { const r = state.price_ranges[c] || {}; if (r.from) totalFrom += parseInt(r.from, 10) || 0; if (r.to) totalTo += parseInt(r.to, 10) || 0; }); const rows = state.categories.map(c => { const cat = PODBOR_CATEGORIES.find(x => x.key === c); const r = state.price_ranges[c] || {}; return `
${cat.label}
`; }).join(""); const totalLine = (totalFrom || totalTo) ? `
Итого: ${formatRub(totalFrom)} — ${formatRub(totalTo)} ₽
` : `
Сумма посчитается автоматически
`; const node = el(`

Ценовой
коридор

«От — До» по каждой категории. AI подберёт варианты, которые попадают в коридор и совокупно укладываются в общий бюджет клиента.

По категориям, ₽
${rows}
${totalLine}
`); node.querySelectorAll("[data-price]").forEach(inp => { inp.addEventListener("input", e => { const [cat, key] = e.target.dataset.price.split("."); const next = { ...state.price_ranges, [cat]: { ...(state.price_ranges[cat] || {}), [key]: e.target.value } }; update({ price_ranges: next }); render(); }); }); bindNav(node); return node; } function formatRub(n) { if (!n) return "—"; return Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); } /* ===================== Step: infra ===================== */ function renderInfra() { const node = el(`

Инфраструктура
кухни

Газ или электрика — определит тип варочной (индукция / стеклокерамика / газ). Подключение вытяжки — нужны ли выводы или угольный фильтр.

Подключение варочной
${PODBOR_INFRA.stove.map(o => ` `).join("")}
Вытяжка → внутридомовая вентиляция?
${PODBOR_INFRA.vent.map(o => ` `).join("")}
Если «Нет» — менеджер закладывает угольный фильтр. Если «Да» — заранее планируем выводы.
`); node.querySelectorAll("[data-infra]").forEach(b => { b.addEventListener("click", () => { update({ infra: { ...state.infra, [b.dataset.infra]: b.dataset.val } }); render(); }); }); bindNav(node); return node; } /* ===================== Step: priorities (что важно при выборе) ===================== */ function renderPriorities() { const node = el(`

Что важно
при выборе?

Бюджет уже задал коридор. Здесь — что AI должен использовать как тай-брейк, когда варианты примерно равны по цене.

Приоритеты
${PODBOR_PRIORITIES.map(o => ` `).join("")}
Можно несколько · в порядке выбора
`); node.querySelectorAll("[data-pri]").forEach(b => { b.addEventListener("click", () => { const cur = state.priorities || []; const key = b.dataset.pri; const next = cur.includes(key) ? cur.filter(x => x !== key) : [...cur, key]; update({ priorities: next }); render(); }); }); bindNav(node); return node; } /* ===================== Step: brands ===================== */ function renderBrands() { if (!state.categories.length) { return el(`
Сначала выберите категории.
`); } const blocks = state.categories.map(catKey => { const cat = PODBOR_CATEGORIES.find(x => x.key === catKey); const brands = PODBOR_BRANDS[catKey] || { premium: [], middle: [], budget: [] }; const catState = state.brands[catKey] || {}; // Тиры остаются в данных (для аналитики «температуры» клиента), // но визуально просто разный цветовой оттенок чипа — без явного ярлыка. const tierGroup = (tier) => `
${(brands[tier] || []).map(b => { const status = catState[b] || "none"; return ``; }).join("")}
`; return `
${cat.label}
${tierGroup("premium")}${tierGroup("middle")}${tierGroup("budget")}
`; }).join(""); const node = el(`

Бренды
по категориям

Тап — ★ предпочтительно. Дабл — ✓ допустимо. Третий — снять. AI сначала пробует ★, потом ✓.

${blocks}
`); node.querySelectorAll(".chip[data-brand]").forEach(c => { c.addEventListener("click", () => { const catKey = c.dataset.cat, brand = c.dataset.brand; const cur = (state.brands[catKey] || {})[brand] || "none"; const nextStatus = cur === "none" ? "preferred" : cur === "preferred" ? "acceptable" : "none"; const catBrands = { ...(state.brands[catKey] || {}) }; if (nextStatus === "none") delete catBrands[brand]; else catBrands[brand] = nextStatus; update({ brands: { ...state.brands, [catKey]: catBrands } }); render(); }); }); bindNav(node); return node; } /* ===================== Step: summary + submit ===================== */ function renderSummary() { let totalFrom = 0, totalTo = 0; state.categories.forEach(c => { const r = state.price_ranges[c] || {}; totalFrom += parseInt(r.from || "0", 10) || 0; totalTo += parseInt(r.to || "0", 10) || 0; }); const totalRange = (totalFrom || totalTo) ? `${formatRub(totalFrom)} — ${formatRub(totalTo)} ₽` : "—"; const priorityLabels = (state.priorities || []) .map(k => PODBOR_PRIORITIES.find(p => p.key === k)?.label) .filter(Boolean).join(" · "); const node = el(`

Готово
к подбору

Проверьте и отправьте — AI вернёт предложение в чат с ботом.

Клиент${state.client_name || "—"}
Категорий${state.categories.length}
Ценовой коридор${totalRange}
Подключение${PODBOR_INFRA.stove.find(f => f.key === state.infra.stove)?.label || "—"}
Вентиляция${PODBOR_INFRA.vent.find(f => f.key === state.infra.vent)?.label || "—"}
Приоритеты${priorityLabels || "—"}
`); bindInputs(node); bindNav(node); node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node)); return node; } /* ===================== Submit ===================== */ async function onSubmit(node) { const btn = node.querySelector("#submitBtn"); const result = node.querySelector("#submitResult"); btn.disabled = true; btn.innerHTML = ' AI думает...'; result.innerHTML = ""; if (!BACKEND_URL) { result.innerHTML = `
BACKEND_URL не настроен (dev-режим).
`; btn.disabled = false; btn.textContent = "Отправить · AI подберёт"; return; } try { const res = await fetch(`${BACKEND_URL}?path=podbor`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", checklist: state, client_name: state.client_name, }), }); const data = await res.json(); if (data.error) { result.innerHTML = `
Ошибка: ${data.error}
`; } else { result.innerHTML = `
${ICONS.check}
Подбор отправлен в чат бота
Лид #${(data.id || "").slice(0, 6)} · откройте Telegram
`; haptic && haptic("success"); } } catch (e) { result.innerHTML = `
Сеть: ${e.message}
`; } 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 bindNav(node) { node.querySelectorAll("[data-go]").forEach(b => { b.addEventListener("click", () => go(b.dataset.go)); }); } return { mount, go, getState: () => state, reset: () => { state = defaultState(); saveState(); render(); } }; })();