/* ============================================================
Подбор техники — render, state, navigation, submit
============================================================ */
const Podbor = (function () {
const STORAGE_KEY = "zov-podbor-v2";
const STEPS = ["intro", "categories", "pricing", "infra", "priorities", "brands", "summary"];
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',...]
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;
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 "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(`
`);
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: 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 `
`;
}).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(); } };
})();