mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:44:47 +00:00
520 lines
20 KiB
JavaScript
520 lines
20 KiB
JavaScript
/* ============================================================
|
||
Подбор техники — render, state, navigation, submit
|
||
============================================================ */
|
||
|
||
const Podbor = (function () {
|
||
const STORAGE_KEY = "zov-podbor-v1";
|
||
const STEPS = ["intro", "categories", "context", "infra", "scenario", "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: "",
|
||
budget_total: "",
|
||
categories: [], // ['fridge','hob',...]
|
||
niches: {}, // { fridge:{w,h,d}, hob:{w,d}, oven:{w,h,d}, dw:{w,h,d} }
|
||
budget_by_cat: {},// { fridge:80000, hob:50000, ... }
|
||
infra: { stove: "", vent: "" },
|
||
scenario: { family: "", cooking: "", techniques: [], guests: "" },
|
||
brands: {}, // { fridge: {Bosch:'preferred', Liebherr:'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(`<div class="podbor-screen"></div>`);
|
||
root.appendChild(screen);
|
||
|
||
switch (currentStep) {
|
||
case "intro": screen.appendChild(renderIntro()); break;
|
||
case "categories": screen.appendChild(renderCategories()); break;
|
||
case "context": screen.appendChild(renderContext()); break;
|
||
case "infra": screen.appendChild(renderInfra()); break;
|
||
case "scenario": screen.appendChild(renderScenario()); break;
|
||
case "brands": screen.appendChild(renderBrands()); break;
|
||
case "summary": screen.appendChild(renderSummary()); break;
|
||
}
|
||
}
|
||
|
||
/* ===================== Header & progress ===================== */
|
||
|
||
function renderHeader() {
|
||
const h = el(`
|
||
<header class="podbor-header">
|
||
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left}</button>
|
||
<div class="podbor-title">Подбор техники</div>
|
||
<div style="width:28px"></div>
|
||
</header>
|
||
`);
|
||
h.querySelector(".podbor-back").addEventListener("click", () => {
|
||
const idx = STEPS.indexOf(currentStep);
|
||
if (idx <= 0) {
|
||
// Выход из подбора в главный экран кабинета
|
||
location.hash = "";
|
||
location.reload();
|
||
} else {
|
||
go(STEPS[idx - 1]);
|
||
}
|
||
});
|
||
return h;
|
||
}
|
||
|
||
function renderProgress() {
|
||
const idx = STEPS.indexOf(currentStep);
|
||
const total = STEPS.length;
|
||
const pct = Math.round(((idx + 1) / total) * 100);
|
||
const labels = ["Старт", "Категории", "Контекст", "Инфра", "Сценарий", "Бренды", "Подбор"];
|
||
return el(`
|
||
<div class="podbor-progress">
|
||
<div class="podbor-progress-bar"><div class="bar" style="width:${pct}%"></div></div>
|
||
<div class="podbor-progress-meta">
|
||
<span>${labels[idx]}</span><span class="num">${idx + 1}/${total}</span>
|
||
</div>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
/* ===================== Step: intro ===================== */
|
||
|
||
function renderIntro() {
|
||
const node = el(`
|
||
<section class="podbor-step">
|
||
<h2 class="display-title">Подбор техники<br><span class="accent">для клиента</span></h2>
|
||
<p class="lede">7 коротких шагов. Указываем категории, бюджет, инфраструктуру и предпочтения. Дальше AI сам соберёт предложение.</p>
|
||
|
||
<div class="form-row">
|
||
<label class="field">
|
||
<span class="field-label">Клиент</span>
|
||
<input type="text" data-bind="client_name" value="${state.client_name || ""}" placeholder="Например: А. Пестова">
|
||
</label>
|
||
</div>
|
||
<div class="form-row two-col">
|
||
<label class="field">
|
||
<span class="field-label">Телефон</span>
|
||
<input type="tel" data-bind="client_phone" value="${state.client_phone || ""}" placeholder="+7 ...">
|
||
</label>
|
||
<label class="field">
|
||
<span class="field-label">Бюджет на технику, ₽</span>
|
||
<input type="number" data-bind="budget_total" value="${state.budget_total || ""}" placeholder="например 350000">
|
||
</label>
|
||
</div>
|
||
|
||
<div class="podbor-cta-row">
|
||
<button class="btn-primary" data-go="categories">Начать</button>
|
||
</div>
|
||
</section>
|
||
`);
|
||
bindInputs(node);
|
||
bindNav(node);
|
||
return node;
|
||
}
|
||
|
||
/* ===================== Step: categories ===================== */
|
||
|
||
function renderCategories() {
|
||
const grid = PODBOR_CATEGORIES.map(c => `
|
||
<button class="cat-card${state.categories.includes(c.key) ? " active" : ""}" data-cat="${c.key}">
|
||
<div class="cat-icon">${ICONS[c.icon] || ""}</div>
|
||
<div class="cat-label">${c.label}</div>
|
||
${state.categories.includes(c.key) ? `<div class="cat-check">${ICONS.check}</div>` : ""}
|
||
</button>
|
||
`).join("");
|
||
|
||
const node = el(`
|
||
<section class="podbor-step">
|
||
<h2 class="display-title">Какую технику<br><span class="accent">подбираем?</span></h2>
|
||
<p class="lede">Выберите все категории, что нужно подобрать клиенту.</p>
|
||
<div class="cat-grid">${grid}</div>
|
||
<div class="podbor-cta-row">
|
||
<button class="btn-secondary" data-go="intro">Назад</button>
|
||
<button class="btn-primary" data-go="context">Дальше</button>
|
||
</div>
|
||
</section>
|
||
`);
|
||
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: context (ниши + бюджет по категориям) ===================== */
|
||
|
||
function renderContext() {
|
||
const builtinCats = ["fridge", "hob", "oven", "dw"]; // встройка
|
||
const niches = builtinCats.filter(c => state.categories.includes(c)).map(c => {
|
||
const cat = PODBOR_CATEGORIES.find(x => x.key === c);
|
||
const n = state.niches[c] || {};
|
||
return `
|
||
<div class="niche-row">
|
||
<div class="niche-label">${cat.label}</div>
|
||
<div class="niche-inputs">
|
||
<input type="number" data-niche="${c}.w" value="${n.w || ""}" placeholder="Ш">
|
||
<input type="number" data-niche="${c}.h" value="${n.h || ""}" placeholder="В">
|
||
<input type="number" data-niche="${c}.d" value="${n.d || ""}" placeholder="Г">
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
|
||
const budgets = state.categories.map(c => {
|
||
const cat = PODBOR_CATEGORIES.find(x => x.key === c);
|
||
const v = state.budget_by_cat[c] || "";
|
||
return `
|
||
<label class="field-inline">
|
||
<span>${cat.label}</span>
|
||
<input type="number" data-budget="${c}" value="${v}" placeholder="₽">
|
||
</label>
|
||
`;
|
||
}).join("");
|
||
|
||
const node = el(`
|
||
<section class="podbor-step">
|
||
<h2 class="display-title">Размеры<br><span class="accent">и бюджет</span></h2>
|
||
<p class="lede">Если планируется встройка — укажите размеры ниш. Бюджет по категориям помогает AI распределить деньги.</p>
|
||
|
||
${niches ? `
|
||
<div class="block">
|
||
<div class="block-head">Ниши под встройку, мм</div>
|
||
<div class="niche-list">${niches}</div>
|
||
</div>
|
||
` : ""}
|
||
|
||
${budgets ? `
|
||
<div class="block">
|
||
<div class="block-head">Бюджет по категориям, ₽</div>
|
||
<div class="budget-list">${budgets}</div>
|
||
</div>
|
||
` : ""}
|
||
|
||
<div class="podbor-cta-row">
|
||
<button class="btn-secondary" data-go="categories">Назад</button>
|
||
<button class="btn-primary" data-go="infra">Дальше</button>
|
||
</div>
|
||
</section>
|
||
`);
|
||
node.querySelectorAll("[data-niche]").forEach(inp => {
|
||
inp.addEventListener("input", e => {
|
||
const [cat, dim] = e.target.dataset.niche.split(".");
|
||
const next = { ...state.niches, [cat]: { ...(state.niches[cat] || {}), [dim]: e.target.value } };
|
||
update({ niches: next });
|
||
});
|
||
});
|
||
node.querySelectorAll("[data-budget]").forEach(inp => {
|
||
inp.addEventListener("input", e => {
|
||
const cat = e.target.dataset.budget;
|
||
const next = { ...state.budget_by_cat, [cat]: e.target.value };
|
||
update({ budget_by_cat: next });
|
||
});
|
||
});
|
||
bindNav(node);
|
||
return node;
|
||
}
|
||
|
||
/* ===================== Step: infra ===================== */
|
||
|
||
function renderInfra() {
|
||
const node = el(`
|
||
<section class="podbor-step">
|
||
<h2 class="display-title">Инфраструктура<br><span class="accent">кухни</span></h2>
|
||
<p class="lede">Газ или электрика — главный вопрос для варочной. Шахта вентиляции — для вытяжки.</p>
|
||
<div class="block">
|
||
<div class="block-head">Подключение варочной</div>
|
||
<div class="opt-list">
|
||
${PODBOR_INFRA.stove.map(o => `
|
||
<button class="opt${state.infra.stove === o.key ? " on" : ""}" data-infra="stove" data-val="${o.key}">${o.label}</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="block">
|
||
<div class="block-head">Вентиляция для вытяжки</div>
|
||
<div class="opt-list">
|
||
${PODBOR_INFRA.vent.map(o => `
|
||
<button class="opt${state.infra.vent === o.key ? " on" : ""}" data-infra="vent" data-val="${o.key}">${o.label}</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="podbor-cta-row">
|
||
<button class="btn-secondary" data-go="context">Назад</button>
|
||
<button class="btn-primary" data-go="scenario">Дальше</button>
|
||
</div>
|
||
</section>
|
||
`);
|
||
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: scenario ===================== */
|
||
|
||
function renderScenario() {
|
||
const node = el(`
|
||
<section class="podbor-step">
|
||
<h2 class="display-title">Сценарий<br><span class="accent">использования</span></h2>
|
||
<p class="lede">Семья с детьми готовит иначе, чем пара. AI учтёт это в подборе.</p>
|
||
|
||
<div class="block">
|
||
<div class="block-head">Состав семьи</div>
|
||
<div class="opt-list">
|
||
${PODBOR_FAMILY.map(o => `
|
||
<button class="opt${state.scenario.family === o.key ? " on" : ""}" data-scenario="family" data-val="${o.key}">${o.label}</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="block">
|
||
<div class="block-head">Частота готовки</div>
|
||
<div class="opt-list">
|
||
${PODBOR_COOKING.map(o => `
|
||
<button class="opt${state.scenario.cooking === o.key ? " on" : ""}" data-scenario="cooking" data-val="${o.key}">${o.label}</button>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="block">
|
||
<div class="block-head">Любимые техники приготовления</div>
|
||
<div class="opt-list">
|
||
${PODBOR_TECHNIQUES.map(o => `
|
||
<button class="opt${(state.scenario.techniques || []).includes(o.key) ? " on" : ""}" data-tech="${o.key}">${o.label}</button>
|
||
`).join("")}
|
||
</div>
|
||
<div class="hint">Можно несколько</div>
|
||
</div>
|
||
|
||
<div class="podbor-cta-row">
|
||
<button class="btn-secondary" data-go="infra">Назад</button>
|
||
<button class="btn-primary" data-go="brands">Дальше</button>
|
||
</div>
|
||
</section>
|
||
`);
|
||
node.querySelectorAll("[data-scenario]").forEach(b => {
|
||
b.addEventListener("click", () => {
|
||
update({ scenario: { ...state.scenario, [b.dataset.scenario]: b.dataset.val } });
|
||
render();
|
||
});
|
||
});
|
||
node.querySelectorAll("[data-tech]").forEach(b => {
|
||
b.addEventListener("click", () => {
|
||
const cur = state.scenario.techniques || [];
|
||
const key = b.dataset.tech;
|
||
const next = cur.includes(key) ? cur.filter(x => x !== key) : [...cur, key];
|
||
update({ scenario: { ...state.scenario, techniques: next } });
|
||
render();
|
||
});
|
||
});
|
||
bindNav(node);
|
||
return node;
|
||
}
|
||
|
||
/* ===================== Step: brands ===================== */
|
||
|
||
function renderBrands() {
|
||
if (!state.categories.length) {
|
||
return el(`<section class="podbor-step"><div class="empty">Сначала выберите категории.</div></section>`);
|
||
}
|
||
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 tierBlock = (tier) => `
|
||
<div class="tier-row">
|
||
<div class="tier-label">${PODBOR_BUDGET_TIERS.find(t => t.key === tier).label}</div>
|
||
<div class="brand-chips">
|
||
${(brands[tier] || []).map(b => {
|
||
const status = catState[b] || "none";
|
||
return `<button class="chip status-${status}" data-cat="${catKey}" data-brand="${b}">${b}</button>`;
|
||
}).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
return `
|
||
<div class="block">
|
||
<div class="block-head">${cat.label}</div>
|
||
<div class="hint">★ Тап — предпочтительно · ✓ Двойной — допустимо · — Третий — снять</div>
|
||
${tierBlock("premium")}${tierBlock("middle")}${tierBlock("budget")}
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
|
||
const node = el(`
|
||
<section class="podbor-step">
|
||
<h2 class="display-title">Бренды<br><span class="accent">по категориям</span></h2>
|
||
<p class="lede">Какие марки уважаете, какие — допустимы. AI сначала пробует preferred.</p>
|
||
${blocks}
|
||
<div class="podbor-cta-row">
|
||
<button class="btn-secondary" data-go="scenario">Назад</button>
|
||
<button class="btn-primary" data-go="summary">Дальше</button>
|
||
</div>
|
||
</section>
|
||
`);
|
||
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() {
|
||
const node = el(`
|
||
<section class="podbor-step">
|
||
<h2 class="display-title">Готово<br><span class="accent">к подбору</span></h2>
|
||
<p class="lede">Проверьте и отправьте — AI вернёт предложение в чат с ботом.</p>
|
||
<div class="block summary-block">
|
||
<div class="kv"><span>Клиент</span><strong>${state.client_name || "—"}</strong></div>
|
||
<div class="kv"><span>Бюджет</span><strong>${state.budget_total ? state.budget_total + " ₽" : "—"}</strong></div>
|
||
<div class="kv"><span>Категорий</span><strong>${state.categories.length}</strong></div>
|
||
<div class="kv"><span>Семья</span><strong>${PODBOR_FAMILY.find(f => f.key === state.scenario.family)?.label || "—"}</strong></div>
|
||
<div class="kv"><span>Готовка</span><strong>${PODBOR_COOKING.find(f => f.key === state.scenario.cooking)?.label || "—"}</strong></div>
|
||
<div class="kv"><span>Подключение</span><strong>${PODBOR_INFRA.stove.find(f => f.key === state.infra.stove)?.label || "—"}</strong></div>
|
||
</div>
|
||
|
||
<label class="field">
|
||
<span class="field-label">Дополнительные пожелания</span>
|
||
<textarea data-bind="notes" rows="3" placeholder="Что-то особенное от клиента?">${state.notes || ""}</textarea>
|
||
</label>
|
||
|
||
<div class="podbor-cta-row">
|
||
<button class="btn-secondary" data-go="brands">Назад</button>
|
||
<button class="btn-primary" id="submitBtn">Отправить · AI подберёт</button>
|
||
</div>
|
||
|
||
<div id="submitResult" class="submit-result"></div>
|
||
</section>
|
||
`);
|
||
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 = '<span class="spinner-inline"></span> AI думает...';
|
||
result.innerHTML = "";
|
||
|
||
if (!BACKEND_URL) {
|
||
result.innerHTML = `<div class="error">BACKEND_URL не настроен (dev-режим).</div>`;
|
||
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 = `<div class="error">Ошибка: ${data.error}</div>`;
|
||
} else {
|
||
result.innerHTML = `
|
||
<div class="success">
|
||
<div class="success-icon">${ICONS.check}</div>
|
||
<div>
|
||
<div class="success-title">Подбор отправлен в чат бота</div>
|
||
<div class="success-sub">Лид #${(data.id || "").slice(0, 6)} · откройте Telegram</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
haptic && haptic("success");
|
||
}
|
||
} catch (e) {
|
||
result.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
|
||
}
|
||
btn.disabled = false;
|
||
btn.textContent = "Отправить ещё раз";
|
||
}
|
||
|
||
/* ===================== Helpers ===================== */
|
||
|
||
function bindInputs(node) {
|
||
node.querySelectorAll("[data-bind]").forEach(inp => {
|
||
inp.addEventListener("input", e => {
|
||
update({ [e.target.dataset.bind]: e.target.value });
|
||
});
|
||
});
|
||
}
|
||
|
||
function 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(); } };
|
||
})();
|