/* ============================================================ Подбор техники — 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 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 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; } /* Лента выбранных категорий — видна на шагах после "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); 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 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) 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 node = el(`

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

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

`); node.appendChild(grid); const cta = el(`
`); node.appendChild(cta); bindNav(node); return node; } /* ===================== Step: infra ===================== */ function renderInfra() { const cats = state.categories || []; const askStove = cats.includes("hob"); const askVent = cats.includes("hood"); // Если ни одна из релевантных категорий не выбрана — пропускаем шаг if (!askStove && !askVent) { // Автопереход на summary через микропаузу (чтобы пользователь увидел) setTimeout(() => { go("summary"); }, 50); return el(`

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

`); } const stoveBlock = askStove ? `
Подключение варочной
${PODBOR_INFRA.stove.map(o => ` `).join("")}
` : ""; const ventBlock = askVent ? `
Вытяжка → внутридомовая вентиляция?
${PODBOR_INFRA.vent.map(o => ` `).join("")}
Если «Нет» — менеджер закладывает угольный фильтр. Если «Да» — заранее планируем выводы.
` : ""; const lede = (askStove && askVent) ? "Газ или электрика — определит тип варочной. Подключение вытяжки — нужны ли выводы или угольный фильтр." : askStove ? "Газ или электрика — определит тип варочной (индукция / стеклокерамика / газ)." : "Подключение вытяжки — нужны ли выводы в вентшахту или угольный фильтр."; const node = el(`

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

${lede}

${stoveBlock} ${ventBlock}
`); 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 || "—"}
` : ""} ${state.categories.includes("hood") ? `
Вентиляция${PODBOR_INFRA.vent.find(f => f.key === state.infra.vent)?.label || "—"}
` : ""}
`); bindInputs(node); bindNav(node); // Кнопка "Назад" — обходим infra если она авто-пропускается node.querySelector("#summaryBack").addEventListener("click", () => { const cats = state.categories || []; const goTo = (cats.includes("hob") || cats.includes("hood")) ? "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; // Рендер отчёта (если 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(`
`); // Шапка wrap.appendChild(el(`
Отчёт · ${leadId.slice(0, 8)}
${summary ? `

${_esc(summary)}

` : ""}
`)); // Категории 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 ? `
${_esc(catAnalysis)}
` : ""}
`); const modelsWrap = catNode.querySelector(".report-models"); for (const m of models) { modelsWrap.appendChild(_renderModelCard(m)); } // Сравнение if (models.length >= 2) { modelsWrap.appendChild(_renderCompareTable(models)); } 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); } return wrap; } function _renderModelCard(m) { const enriched = m.enriched || {}; const pMin = enriched.price_min_rub || m.price_min_rub; const pMax = enriched.price_max_rub || m.price_max_rub; const img = enriched.image_url; const rating = enriched.rating_max; const reviews = enriched.reviews_total; const stores = enriched.stores_count; const priceHtml = (pMin && pMax && pMin !== pMax) ? `${formatRub(pMin)}${formatRub(pMax)} ₽` : pMin ? `${formatRub(pMin)} ₽` : `цена уточняется`; const metaParts = []; if (rating) metaParts.push(`★ ${Number(rating).toFixed(1)}`); if (reviews) metaParts.push(`${reviews} отзыв.`); if (stores) metaParts.push(`${stores} магазинов`); // Бейджи источников + ссылки const sourcesData = [ { key: "ozon", label: "OZON", item: enriched.ozon }, { key: "citilink", label: "Citilink", item: enriched.citilink }, { key: "wb", label: "Wildberries", item: enriched.wb }, { key: "yamarket", label: "Я.Маркет", item: enriched.yamarket }, { key: "dns", label: "DNS", item: enriched.dns }, ]; const sourceLinks = sourcesData .filter(s => s.item && s.item.url) .map(s => `${s.label}${s.item.price_min_rub ? ` · ${formatRub(s.item.price_min_rub)} ₽` : ""}`); const card = el(`
${img ? `` : ""}
${_esc(m.brand || "")}
${_esc(m.model || "")}
${metaParts.length ? `
${metaParts.join(" · ")}
` : ""}
${priceHtml}
${(m.highlights || []).length ? `
✓ ${m.highlights.map(_esc).join(" · ")}
` : ""} ${(m.pros || []).length ? `
Плюсы
    ${m.pros.slice(0, 4).map(p => `
  • ${_esc(p)}
  • `).join("")}
` : ""} ${(m.cons || []).length ? `
Минусы
    ${m.cons.slice(0, 3).map(c => `
  • ${_esc(c)}
  • `).join("")}
` : ""} ${m.reasoning ? `
💡 ${_esc(m.reasoning)}
` : ""} ${sourceLinks.length ? ` ` : ``}
`); return card; } function _pluralStores(n) { const last = n % 10, lastTwo = n % 100; if (lastTwo >= 11 && lastTwo <= 14) return "магазинов"; if (last === 1) return "магазин"; if (last >= 2 && last <= 4) return "магазина"; return "магазинов"; } function _renderCompareTable(models) { const rows = models.map(m => { const e = m.enriched || {}; const p = m.price_min_rub || e.price_min_rub; return ` ${_esc(m.brand || "")} ${_esc(m.model || "")} ${p ? formatRub(p) + " ₽" : "—"} ${e.reviews_total || "—"} ${e.rating_max ? Number(e.rating_max).toFixed(1) : "—"} `; }).join(""); return el(`
Сравнить модели ${rows}
МодельЦена отОтзывов
`); } function _esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /* ===================== Helpers ===================== */ function bindInputs(node) { node.querySelectorAll("[data-bind]").forEach(inp => { inp.addEventListener("input", e => { update({ [e.target.dataset.bind]: e.target.value }); }); // Нормализация телефона на blur if (inp.dataset.bind === "client_phone") { inp.addEventListener("blur", e => { const normalized = normalizePhone(e.target.value); if (normalized && normalized !== e.target.value) { e.target.value = normalized; update({ client_phone: normalized }); } }); } }); } /* Приводим телефон к единому формату +7 XXX XXX-XX-XX. Принимает: 8XXXXXXXXXX, 7XXXXXXXXXX, +7XXXXXXXXXX, 9XXXXXXXXX (без префикса). */ function normalizePhone(raw) { if (!raw) return ""; const digits = raw.replace(/\D/g, ""); let d = digits; if (d.length === 11 && d.startsWith("8")) d = "7" + d.slice(1); if (d.length === 10 && d.startsWith("9")) d = "7" + d; // мобильный без префикса if (d.length !== 11 || !d.startsWith("7")) return raw.trim(); // не похоже на РФ-номер — не трогаем return `+7 ${d.slice(1, 4)} ${d.slice(4, 7)}-${d.slice(7, 9)}-${d.slice(9, 11)}`; } 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(); } }; })();