/* ============================================================ Подбор техники — render, state, navigation, submit ============================================================ */ const Podbor = (function () { const STORAGE_KEY = "zov-podbor-v4"; const STEPS = ["intro", "categories", "detail", "brand", "budget", "strategy", "infra", "summary"]; const STEP_LABELS = ["Старт", "Категории", "Параметры", "Бренд", "Бюджет", "Стратегия", "Инфра", "Итог"]; // Внутренний 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) { const parsed = JSON.parse(raw); // Мерж с дефолтами для совместимости с новыми полями return { ...defaultState(), ...parsed }; } } catch (e) {} return defaultState(); } function defaultState() { return { client_name: "", client_phone: "", address: "", categories: [], // ['fridge','hob',...] per_cat: {}, // { fridge: { answers: {install:'built_in',...}, notes: '', _step: 0 } } brand_strategy: "", // 'ai' | 'single' | 'different' single_brand: "", // key из PODBOR_SINGLE_BRAND_OPTIONS, если brand_strategy === 'single' brands: {}, // если brand_strategy === 'different' — { fridge: {Bosch:'preferred'|'acceptable'|'avoid'} } budget_preset: "", // 'luxe'|'premium'|'middle'|'budget'|'exact' price_ranges: {}, // только если budget_preset === 'exact' pick_strategies: [], // ['reviews','balance','tech',...] — multi model_count: "5", // '3' | '5' | '7' — сколько моделей в каждой категории infra: { stove: "", vent: "" }, 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 strip = renderCategoryStrip(); if (strip) root.appendChild(strip); 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 "brand": screen.appendChild(renderBrand()); break; case "budget": screen.appendChild(renderBudget()); break; case "strategy": screen.appendChild(renderStrategy()); break; case "infra": screen.appendChild(renderInfra()); break; case "summary": screen.appendChild(renderSummary()); break; } } /* ===================== Header & progress ===================== */ function _goHome() { location.hash = ""; if (typeof routeByHash === "function") routeByHash(); } function renderHeader() { const h = el(`
Подбор техники
`); h.querySelector(".podbor-back").addEventListener("click", () => { const idx = STEPS.indexOf(currentStep); if (idx <= 0) { _goHome(); } else { go(STEPS[idx - 1]); } }); h.querySelector(".podbor-home").addEventListener("click", () => { haptic && haptic("impact"); _goHome(); }); return h; } /* Лента выбранных категорий — видна на шагах после "categories" */ function renderCategoryStrip() { if (!state.categories.length) return null; if (currentStep === "intro" || currentStep === "categories") return null; // Активная категория — если внутри wizard'а одной из них let activeCat = null; if (currentStep === "detail" && detailView.startsWith("cat:")) { activeCat = detailView.slice(4); } const chips = state.categories.map(catKey => { const cat = PODBOR_CATEGORIES.find(c => c.key === catKey); const filled = isCategoryFilled(catKey); const isActive = catKey === activeCat; return ` `; }).join(""); const node = el(`
${chips}
`); node.querySelectorAll(".cat-strip-chip").forEach(btn => { btn.addEventListener("click", () => { const cat = btn.dataset.cat; currentStep = "detail"; detailView = "cat:" + cat; render(); window.scrollTo({ top: 0, behavior: "smooth" }); haptic && haptic("impact"); }); }); return node; } function renderProgress() { const idx = STEPS.indexOf(currentStep); const total = STEPS.length; const pct = Math.round(((idx + 1) / total) * 100); return el(`
${STEP_LABELS[idx] || ""}${idx + 1}/${total}
`); } /* ===================== Step: intro ===================== */ function renderIntro() { const node = el(`

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

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

`); bindInputs(node); const phoneInput = node.querySelector("input[data-bind='client_phone']"); const phoneError = node.querySelector("#phoneError"); const nameInput = node.querySelector("input[data-bind='client_name']"); const nameError = node.querySelector("#nameError"); // Валидация на blur — мягкие подсказки phoneInput.addEventListener("blur", () => { const v = phoneInput.value.trim(); if (v && !isValidPhone(v)) { phoneError.textContent = "Похоже на неполный номер. Нужно 11 цифр (или 10 с цифры 9)"; } else { phoneError.textContent = ""; } }); node.querySelector("#introNext").addEventListener("click", () => { // Имя const name = (state.client_name || "").trim(); if (!name) { nameError.textContent = "Укажите имя клиента"; nameInput.focus(); haptic && haptic("warning"); return; } else { nameError.textContent = ""; } // Телефон const phone = (state.client_phone || "").trim(); if (!phone) { phoneError.textContent = "Укажите телефон клиента"; phoneInput.focus(); haptic && haptic("warning"); return; } if (!isValidPhone(phone)) { phoneError.textContent = "Неверный формат. Пример: +7 900 123-45-67 или 89001234567"; phoneInput.focus(); haptic && haptic("warning"); return; } // Нормализуем перед переходом const normalized = normalizePhone(phone); if (normalized !== phone) { update({ client_phone: normalized }); } go("categories"); }); 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 cs = state.per_cat[catKey]; if (!cs) return false; const config = PODBOR_PARAMS[catKey]; if (!config) return false; // Новая схема: все активные single-шаги должны иметь ответ. Multi (features) — необязательно. if (config.steps) { const ans = cs.answers || {}; return config.steps.every(step => { if (!isStepActive(step, ans)) return true; // неактивный пропускаем if (step.type === "multi") return true; // multi необязателен return !!ans[step.key]; }); } // Старая схема if (!cs.params) return false; const params = config.primary || []; return params.every(p => cs.params[p.key]); } function renderDetail() { if (!state.categories.length) { return el(`
Сначала выберите категории.
`); } if (detailView !== "menu" && detailView.startsWith("cat:")) { const catKey = detailView.slice(4); const config = PODBOR_PARAMS[catKey]; // Новая иерархическая схема → wizard. Старая → legacy-форма. if (config?.steps) return renderCategoryWizard(catKey); 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 cs = state.per_cat[catKey]; if (!cs) return "—"; const config = PODBOR_PARAMS[catKey]; // Новая схема if (config?.steps) { const ans = cs.answers || {}; const labels = []; for (const step of config.steps) { if (step.type === "multi") continue; if (!isStepActive(step, ans)) continue; const val = ans[step.key]; if (!val) continue; const opts = resolveStepOptions(step, ans); const opt = opts.find(o => o.key === val); if (opt) labels.push(opt.label); } return labels.join(" · ") || "—"; } // Старая схема if (!cs.params) return "—"; const params = config?.primary || []; const labels = params .map(p => { const opt = p.options.find(o => o.key === cs.params[p.key]); return opt ? opt.label : null; }) .filter(Boolean); return labels.join(" · ") || "—"; } /* Возвращает реальный options[] для шага с учётом optionsBy */ function resolveStepOptions(step, answers) { if (step.options) return step.options; if (step.optionsBy) { const depVal = answers[step.optionsBy.dependsOn]; return (step.optionsBy.map && step.optionsBy.map[depVal]) || []; } return []; } /* Активен ли шаг (выполняется ли condition) */ function isStepActive(step, answers) { if (!step.condition) return true; for (const [key, expected] of Object.entries(step.condition)) { const actual = answers[key]; const ok = Array.isArray(expected) ? expected.includes(actual) : actual === expected; if (!ok) return false; } return true; } /* Найти следующий активный шаг (или steps.length если все после inactive) */ function findNextActiveIdx(steps, fromIdx, answers) { for (let i = fromIdx + 1; i < steps.length; i++) { if (isStepActive(steps[i], answers)) return i; } return steps.length; } /* Найти предыдущий активный (или -1) */ function findPrevActiveIdx(steps, fromIdx, answers) { for (let i = fromIdx - 1; i >= 0; i--) { if (isStepActive(steps[i], answers)) return i; } return -1; } /* ===================== Иерархический wizard внутри категории ===================== */ function getCatState(catKey) { const cs = state.per_cat[catKey]; if (cs && cs.answers) { // Чистим ответы для шагов, которых больше нет в текущей схеме // (нужно при обновлении логики категории: например убрали класс энерго у ПММ) const config = PODBOR_PARAMS[catKey]; if (config?.steps) { const validKeys = new Set(config.steps.map(s => s.key)); const filtered = {}; for (const [k, v] of Object.entries(cs.answers)) { if (validKeys.has(k)) filtered[k] = v; } if (Object.keys(filtered).length !== Object.keys(cs.answers).length) { cs.answers = filtered; } } return cs; } // Миграция / инициализация return { answers: {}, notes: cs?.notes || "", _step: 0 }; } function setCatState(catKey, patch) { const prev = getCatState(catKey); const next = { ...prev, ...patch }; update({ per_cat: { ...state.per_cat, [catKey]: next } }); } function renderCategoryWizard(catKey) { const cat = PODBOR_CATEGORIES.find(x => x.key === catKey); const config = PODBOR_PARAMS[catKey]; const cs = getCatState(catKey); let stepIdx = Math.max(0, Math.min(cs._step || 0, config.steps.length)); // Нормализация: пропускаем неактивные шаги вперёд while (stepIdx < config.steps.length && !isStepActive(config.steps[stepIdx], cs.answers)) { stepIdx++; } // Финальный экран категории — обзор + заметки + кнопка "Готово" if (stepIdx >= config.steps.length) { return renderCategoryReview(catKey); } const step = config.steps[stepIdx]; const options = resolveStepOptions(step, cs.answers); const isMulti = step.type === "multi"; const currentVal = cs.answers[step.key]; const currentArr = isMulti ? (Array.isArray(currentVal) ? currentVal : []) : null; // Чипы прошлых ответов (single-шаги) const prevChips = config.steps.slice(0, stepIdx) .filter(s => s.type !== "multi") .map(s => { const v = cs.answers[s.key]; if (!v) return ""; const opts = resolveStepOptions(s, cs.answers); const o = opts.find(x => x.key === v); return o ? `${o.label}` : ""; }).join(""); // Определяем layout: если ни у одной опции нет pict — компактные пин-кнопки, // иначе — крупные карточки с пиктограммами. const hasPicts = options.some(o => o.pict && PODBOR_PICTS[o.pict]); const gridMode = hasPicts ? "cards" : "pins"; const cardsHtml = options.map(o => { const isOn = isMulti ? currentArr.includes(o.key) : currentVal === o.key; const pict = o.pict && PODBOR_PICTS[o.pict]; const cardCls = "wiz-card" + (hasPicts ? "" : " wiz-card--pin") + (isOn ? " on" : "") + (o.star ? " star" : ""); if (hasPicts) { return ` `; } // Пин-режим: компактная inline-кнопка с label, опц. hint мелкий справа return ` `; }).join(""); const stepNum = stepIdx + 1; const stepTotal = config.steps.length; const node = el(`
${cat.label}
Шаг ${stepNum} из ${stepTotal}
${ICONS[cat.icon] || ""}
${prevChips ? `
${prevChips}
` : ""}

${step.title}${isMulti ? ' · можно несколько' : ""}

${cardsHtml}
${stepIdx > 0 ? `` : `` } ${isMulti ? `` : (currentVal ? `` : "") }
`); // Клик по карточке node.querySelectorAll(".wiz-card").forEach(card => { card.addEventListener("click", () => { const val = card.dataset.val; const cs2 = getCatState(catKey); const newAns = { ...cs2.answers }; if (isMulti) { const arr = Array.isArray(newAns[step.key]) ? newAns[step.key] : []; newAns[step.key] = arr.includes(val) ? arr.filter(x => x !== val) : [...arr, val]; } else { newAns[step.key] = val; // Если меняем answer для шага, от которого зависят следующие — сбросим их answers for (let i = stepIdx + 1; i < config.steps.length; i++) { const s = config.steps[i]; if (s.optionsBy && s.optionsBy.dependsOn === step.key) { delete newAns[s.key]; } if (s.condition && Object.prototype.hasOwnProperty.call(s.condition, step.key)) { delete newAns[s.key]; } } } setCatState(catKey, { answers: newAns }); // Single-select: автопереход на следующий АКТИВНЫЙ шаг if (!isMulti) { const nextIdx = findNextActiveIdx(config.steps, stepIdx, newAns); setCatState(catKey, { _step: nextIdx }); haptic && haptic("impact"); } render(); }); }); // Чипы — клик возвращает к шагу node.querySelectorAll(".wiz-chip[data-jump]").forEach(chip => { chip.addEventListener("click", () => { const targetKey = chip.dataset.jump; const targetIdx = config.steps.findIndex(s => s.key === targetKey); if (targetIdx >= 0) { setCatState(catKey, { _step: targetIdx }); render(); } }); }); // Кнопки — через активные шаги const wizPrev = node.querySelector("#wizPrev"); if (wizPrev) wizPrev.addEventListener("click", () => { const prevIdx = findPrevActiveIdx(config.steps, stepIdx, cs.answers); setCatState(catKey, { _step: Math.max(0, prevIdx) }); render(); }); const wizMenu = node.querySelector("#wizMenu"); if (wizMenu) wizMenu.addEventListener("click", () => { detailView = "menu"; render(); }); const wizNext = node.querySelector("#wizNext"); if (wizNext) wizNext.addEventListener("click", () => { const nextIdx = findNextActiveIdx(config.steps, stepIdx, cs.answers); setCatState(catKey, { _step: nextIdx }); haptic && haptic("impact"); render(); }); // Header back — на предыдущий активный или к меню node.querySelector(".podbor-back").addEventListener("click", () => { const prevIdx = findPrevActiveIdx(config.steps, stepIdx, cs.answers); if (prevIdx >= 0) { setCatState(catKey, { _step: prevIdx }); render(); } else { detailView = "menu"; render(); } }); return node; } function renderCategoryReview(catKey) { const cat = PODBOR_CATEGORIES.find(x => x.key === catKey); const config = PODBOR_PARAMS[catKey]; const cs = getCatState(catKey); const rows = config.steps.filter(step => isStepActive(step, cs.answers)).map(step => { const v = cs.answers[step.key]; const opts = resolveStepOptions(step, cs.answers); if (step.type === "multi") { const arr = Array.isArray(v) ? v : []; const labels = arr.map(k => opts.find(o => o.key === k)?.label).filter(Boolean); return `
${step.title}
${labels.length ? labels.join(" · ") : 'не выбрано'}
`; } const opt = opts.find(o => o.key === v); return `
${step.title}
${opt ? opt.label : ''}
`; }).join(""); const node = el(`
${cat.label}
Готово
${ICONS[cat.icon] || ""}

Проверьте ответы

${rows}
`); node.querySelector("#wizEdit").addEventListener("click", () => { setCatState(catKey, { _step: 0 }); render(); }); node.querySelector("#wizDone").addEventListener("click", () => { detailView = "menu"; haptic && haptic("success"); render(); }); node.querySelector(".podbor-back").addEventListener("click", () => { setCatState(catKey, { _step: config.steps.length - 1 }); render(); }); const ta = node.querySelector("textarea[data-bind='cat_notes']"); if (ta) ta.addEventListener("input", e => { setCatState(catKey, { notes: e.target.value }); }); return node; } 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; } function formatRub(n) { if (!n) return "—"; return Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); } /* Универсальный рендер пин-карточек (label + hint, single или multi) */ function renderPinCards(items, getStatus, onClick, opts = {}) { const html = items.map(o => { const status = getStatus(o); // 'on' | 'on-star' | '' const isOn = status === "on" || status === "on-star"; const cls = "wiz-card wiz-card--pin" + (isOn ? " on" : "") + (o.recommended ? " star" : ""); return ` `; }).join(""); const wrap = el(`
${html}
`); wrap.querySelectorAll(".wiz-card").forEach(btn => { btn.addEventListener("click", () => { onClick(btn.dataset.key); }); }); return wrap; } /* ===================== Step: brand (бренд-стратегия + выбор) ===================== */ function renderBrand() { const bs = state.brand_strategy || ""; const strategyGrid = renderPinCards( PODBOR_BRAND_STRATEGY, o => (bs === o.key ? "on" : ""), key => { update({ brand_strategy: key }); render(); } ); // Подблок зависит от выбранной стратегии let subBlock = ""; if (bs === "single") { const sb = state.single_brand || ""; const cardsHtml = PODBOR_SINGLE_BRAND_OPTIONS.map(o => { const on = sb === o.key; return ` `; }).join(""); subBlock = `
Какая марка
${cardsHtml}
`; } else if (bs === "different") { // Чипы по категориям с 4-state статусами (none → preferred → acceptable → avoid → none) 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(""); subBlock = `
Тап — ★ хочу · повторно — ✓ согласен · третий — ✗ не хочу · четвёртый — снять
${blocks} `; } else if (bs === "ai") { subBlock = `
AI подберёт оптимальный микс брендов под выбранный бюджет и стратегию. Можно ничего больше не указывать.
`; } const node = el(`

Бренд
стратегия

Хочет ли клиент всю технику от одной марки, или собираем оптимальный микс?

`); node.appendChild(strategyGrid); if (subBlock) { const sub = el(`
${subBlock}
`); node.appendChild(sub); // Single-brand chips sub.querySelectorAll("[data-sb]").forEach(b => { b.addEventListener("click", () => { update({ single_brand: b.dataset.sb }); render(); }); }); // Different-brand 4-state cycle sub.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" : cur === "acceptable" ? "avoid" : "none"; const catBrands = { ...(state.brands[catKey] || {}) }; if (nextStatus === "none") delete catBrands[brand]; else catBrands[brand] = nextStatus; update({ brands: { ...state.brands, [catKey]: catBrands } }); render(); }); }); } const cta = el(`
`); node.appendChild(cta); bindNav(node); return node; } function tierLabel(tier) { return tier === "premium" ? "премиум" : tier === "middle" ? "средний" : tier === "budget" ? "бюджет" : ""; } /* ===================== Step: budget (пресет или точные цифры) ===================== */ function renderBudget() { const bp = state.budget_preset || ""; // Считаем суммарную долю выбранных категорий от полного комплекта const share = (state.categories || []).reduce( (s, c) => s + (PODBOR_BUDGET_SHARES[c] || 0), 0 ) / 100; const shareSafe = share > 0 ? share : 1; // Подмешиваем hint с реальной вилкой для выбранных категорий const presetsWithRange = PODBOR_BUDGET_PRESETS.map(o => { if (o.key === "exact") return { ...o, hint: o.desc }; const r = PODBOR_BUDGET_RANGES[o.key]; if (!r) return { ...o, hint: o.desc }; const from = Math.round(r.from * shareSafe); const to = Math.round(r.to * shareSafe); const label = from >= 1000 ? `${(from/1000).toFixed(1)}–${(to/1000).toFixed(1)}М ₽` : `${from}–${to} тыс ₽`; return { ...o, hint: `${label} · ${o.desc}` }; }); const presetGrid = renderPinCards( presetsWithRange, o => (bp === o.key ? "on" : ""), key => { update({ budget_preset: key }); render(); } ); // Если "exact" — показываем поля от-до по категориям let exactBlock = null; if (bp === "exact") { 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(""); exactBlock = el(`
По категориям, ₽
${rows}
${ (totalFrom || totalTo) ? `Итого: ${formatRub(totalFrom)} — ${formatRub(totalTo)} ₽` : `Сумма посчитается автоматически` }
`); // Внимание: НЕ вызываем render() на input — иначе клавиатура слетает exactBlock.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 }); // Локально пересчитываем сумму let tf = 0, tt = 0; state.categories.forEach(c => { const r = state.price_ranges[c] || {}; if (r.from) tf += parseInt(r.from, 10) || 0; if (r.to) tt += parseInt(r.to, 10) || 0; }); const line = exactBlock.querySelector("#priceTotalLine"); if (line) { line.innerHTML = (tf || tt) ? `Итого: ${formatRub(tf)} — ${formatRub(tt)} ₽` : `Сумма посчитается автоматически`; } }); }); } const node = el(`

Бюджет
на технику

Выбери диапазон. AI сам распределит бюджет по категориям (холодильник ~25%, варочная ~15%, духовка ~15% и т.д.).

`); node.appendChild(presetGrid); if (exactBlock) node.appendChild(exactBlock); const cta = el(`
`); node.appendChild(cta); bindNav(node); return node; } /* ===================== Step: strategy (что важно при подборе — multi) ===================== */ function renderStrategy() { const cur = state.pick_strategies || []; const grid = renderPinCards( PODBOR_PICK_STRATEGIES, o => (cur.includes(o.key) ? "on" : ""), key => { const next = cur.includes(key) ? cur.filter(x => x !== key) : [...cur, key]; update({ pick_strategies: next }); render(); } ); // Количество моделей в категории const mc = state.model_count || "5"; const countGrid = renderPinCards( PODBOR_MODEL_COUNTS, o => (mc === o.key ? "on" : ""), key => { update({ model_count: key }); render(); } ); const node = el(`

Стратегия
подбора

Что для клиента важно при выборе? Можно несколько — AI учтёт всё.

`); node.appendChild(grid); const countHead = el(`
Сколько моделей предложить
`); countHead.appendChild(countGrid); node.appendChild(countHead); const cta = el(`
`); node.appendChild(cta); bindNav(node); return node; } /* ===================== Step: infra ===================== */ function renderInfra() { const cats = state.categories || []; const askStove = cats.includes("hob"); // Вытяжка НЕ спрашиваем здесь — у hood-категории уже есть шаг "Подключение" // (Отвод в вентшахту / Рециркуляция / Универсальная) — это дубль. // Если варочная не выбрана — пропускаем шаг полностью if (!askStove) { setTimeout(() => { go("summary"); }, 50); return el(`

Инфраструктурные вопросы для выбранных категорий не требуются — переходим к итогу...

`); } const node = el(`

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

Газ или электрика — определит тип варочной (индукция требует 380В, обычная электро 220В).

Подключение варочной
${PODBOR_INFRA.stove.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: summary + submit ===================== */ function renderSummary() { // Бренд-стратегия const bs = state.brand_strategy; const bsLabel = PODBOR_BRAND_STRATEGY.find(s => s.key === bs)?.label || "—"; let brandDetail = ""; if (bs === "single") { const sb = PODBOR_SINGLE_BRAND_OPTIONS.find(o => o.key === state.single_brand); brandDetail = sb ? ` · ${sb.label}` : ""; } else if (bs === "different") { const totalBrands = Object.values(state.brands || {}).reduce((s, c) => s + Object.keys(c || {}).length, 0); brandDetail = totalBrands ? ` · ${totalBrands} отметок` : ""; } // Бюджет const bp = state.budget_preset; const bpDef = PODBOR_BUDGET_PRESETS.find(p => p.key === bp); let budgetLabel = bpDef?.label || "—"; if (bp === "exact") { 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; }); if (totalFrom || totalTo) budgetLabel = `${formatRub(totalFrom)} — ${formatRub(totalTo)} ₽`; } else if (bpDef?.hint) { budgetLabel = `${bpDef.label} · ${bpDef.hint}`; } // Стратегия подбора const strategyLabels = (state.pick_strategies || []) .map(k => PODBOR_PICK_STRATEGIES.find(s => s.key === k)?.label) .filter(Boolean).join(" · "); const node = el(`

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

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

Клиент${state.client_name || "—"}
Категорий${state.categories.length}
Бренд${bsLabel}${brandDetail}
Бюджет${budgetLabel}
Стратегия${strategyLabels || "—"}
${state.categories.includes("hob") ? `
Подключение${PODBOR_INFRA.stove.find(f => f.key === state.infra.stove)?.label || "—"}
` : ""}
`); bindInputs(node); bindNav(node); // Кнопка "Назад" — обходим infra если она авто-пропускается node.querySelector("#summaryBack").addEventListener("click", () => { const cats = state.categories || []; // Infra-шаг показывается только если выбрана варочная (для электро/газ). // Вытяжка задаёт свою вентиляцию в hood-step "Подключение". const goTo = cats.includes("hob") ? "infra" : "strategy"; go(goTo); }); 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 normPhone = normalizePhone(state.client_phone || ""); if (normPhone && normPhone !== state.client_phone) { update({ client_phone: normPhone }); } const res = await fetch(`${BACKEND_URL}/api/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 { // Успех + красивый inline-отчёт под кнопкой const headSuccess = `
${ICONS.check}
Подбор готов
Лид #${(data.id || "").slice(0, 6)} · также отправлен в Telegram
`; result.innerHTML = headSuccess; // Кнопка "Вернуться в главное" сразу после успеха const homeBtn = el(`
`); homeBtn.querySelector("button").addEventListener("click", () => { haptic && haptic("impact"); _goHome(); }); result.appendChild(homeBtn); // Рендер отчёта (если AI вернул by_category) if (data.ai) { const reportNode = renderReport(data.ai, data.id || ""); result.appendChild(reportNode); } haptic && haptic("success"); // Скроллим к отчёту setTimeout(() => result.scrollIntoView({ behavior: "smooth", block: "start" }), 100); } } catch (e) { result.innerHTML = `
Сеть: ${e.message}
`; } btn.disabled = false; btn.textContent = "Отправить ещё раз"; } /* ===================== Отчёт (inline, в шаге summary) ===================== */ function renderReport(ai, leadId) { const summary = ai.summary || ""; const byCat = ai.by_category || {}; const total = ai.total_price_estimate_rub || {}; const budgetStatus = ai.budget_status || ""; const warnings = ai.warnings || []; const wrap = el(`
`); // Шапка const headNode = el(`
Отчёт · ${leadId.slice(0, 8)}
`); if (summary) { const sumP = document.createElement("p"); sumP.className = "report-summary"; sumP.innerHTML = _ai(summary); headNode.appendChild(sumP); } wrap.appendChild(headNode); // Категории for (const [catKey, catData] of Object.entries(byCat)) { const catMeta = PODBOR_CATEGORIES.find(c => c.key === catKey); const catLabel = catMeta?.label || catKey; const catIcon = catMeta?.icon; const models = (catData && catData.models) || []; const catAnalysis = (catData && catData.analysis) || ""; if (!models.length) continue; const catNode = el(`

${(catIcon && ICONS[catIcon]) || ""} ${_esc(catLabel)}

${catAnalysis ? `
` : ""}
`); if (catAnalysis) { catNode.querySelector(".report-cat-analysis").innerHTML = _ai(catAnalysis); } // Сравнение цен — основной блок, всегда вверху const matrixNode = _renderPriceMatrix(models); if (matrixNode) catNode.appendChild(matrixNode); // Детальные карточки с pros/cons const modelsWrap = el(`
`); for (const m of models) { modelsWrap.appendChild(_renderModelCard(m)); } catNode.appendChild(modelsWrap); wrap.appendChild(catNode); } // Итого if (total && (total.min || total.max)) { const tmin = total.min, tmax = total.max; const range = (tmin && tmax && tmin !== tmax) ? `${formatRub(tmin)} — ${formatRub(tmax)} ₽` : `${formatRub(tmin || tmax)} ₽`; wrap.appendChild(el(`
ИТОГО ${range} ${budgetStatus ? `${_esc(budgetStatus)}` : ""}
`)); } // Предупреждения if (warnings.length) { const wn = el(`
`); warnings.forEach(w => wn.appendChild(el(`
⚠️ ${_esc(w)}
`))); wrap.appendChild(wn); } // Кнопки экспорта в конце const exportNode = el(`
Сохранить отчёт
HTML удобно отправить клиенту в мессенджер · PDF — для печати или вложения
`); exportNode.querySelector("#exportHtml").addEventListener("click", () => _exportReportHtml(ai, leadId)); exportNode.querySelector("#exportPrint").addEventListener("click", () => _exportReportPrint(wrap, leadId)); wrap.appendChild(exportNode); return wrap; } /* Экспорт: HTML файл с inline стилями и данными — скачивается */ function _exportReportHtml(ai, leadId) { // Создаём stand-alone HTML вокруг текущего DOM отчёта const reportEl = document.querySelector(".report"); if (!reportEl) return; // Копируем все стили со страницы (link + style) const stylesheets = []; document.querySelectorAll("link[rel='stylesheet'], style").forEach(s => stylesheets.push(s.outerHTML)); const html = ` Подбор техники · ${leadId.slice(0,8)} ${stylesheets.join("\n")}

Подбор техники · клиент: ${_esc(state.client_name || "—")}

${reportEl.outerHTML} `; const blob = new Blob([html], { type: "text/html;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `podbor-${leadId.slice(0,8)}-${(state.client_name || "client").replace(/\s+/g, "_")}.html`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); haptic && haptic("success"); } /* Экспорт: системный print-диалог. На Telegram WebApp popups часто блокируются, поэтому печатаем inline — `@media print` в podbor.css скрывает всё лишнее. */ function _exportReportPrint(reportNode, leadId) { const isTelegram = !!(window.Telegram && window.Telegram.WebApp); // Сначала пробуем открыть отдельное окно (на десктопе/Safari чище) if (!isTelegram) { const reportEl = document.querySelector(".report"); if (reportEl) { const stylesheets = []; document.querySelectorAll("link[rel='stylesheet'], style").forEach(s => stylesheets.push(s.outerHTML)); const w = window.open("", "_blank"); if (w) { w.document.write(` Подбор техники · ${leadId.slice(0,8)} · Печать ${stylesheets.join("\n")}

Подбор техники · ${_esc(state.client_name || "—")}

${reportEl.outerHTML}