feat(miniapp): «Подбор техники» screen — 7-step picker (categories/niches/budget/infra/scenario/brands/summary) wired to /api/podbor

This commit is contained in:
wasrusgen 2026-05-09 13:34:46 +03:00
parent 86cd4eb614
commit d1165d5e4f
7 changed files with 3417 additions and 8 deletions

File diff suppressed because it is too large Load Diff

View File

@ -175,10 +175,10 @@ function renderManagerHome(me) {
// Quick actions // Quick actions
const quickActions = [ const quickActions = [
{ icon: "camera", title: "Новый замер", subtitle: "С фото" }, { icon: "camera", title: "Новый замер", subtitle: "С фото", href: null },
{ icon: "cube", title: "3D просмотр", subtitle: "Проекты" }, { icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null },
{ icon: "bolt", title: "Коммуникации", subtitle: "Чек-лист" }, { icon: "bolt", title: "Коммуникации", subtitle: "Чек-лист", href: null },
{ icon: "package", title: "Каталог техники", subtitle: "Встройка" }, { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
]; ];
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`)); app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
const grid = el(`<div class="quick-grid"></div>`); const grid = el(`<div class="quick-grid"></div>`);
@ -190,7 +190,11 @@ function renderManagerHome(me) {
<div class="subtitle">${qa.subtitle}</div> <div class="subtitle">${qa.subtitle}</div>
</button> </button>
`); `);
card.addEventListener("click", () => { haptic("impact"); tg?.showAlert?.(`«${qa.title}» — скоро`); }); card.addEventListener("click", () => {
haptic("impact");
if (qa.href) location.hash = qa.href;
else tg?.showAlert?.(`«${qa.title}» — скоро`);
});
grid.appendChild(card); grid.appendChild(card);
}); });
app.appendChild(grid); app.appendChild(grid);
@ -350,8 +354,16 @@ function renderError() {
/* ----------------- Init ----------------- */ /* ----------------- Init ----------------- */
async function init() { async function init() {
setupTelegram(); setupTelegram();
// Hash-роутер: позволяет открывать подэкраны (например подбор) напрямую
window.addEventListener("hashchange", routeByHash);
try { try {
const me = await fetchMe(); const me = await fetchMe();
window.__zovMe = me; // кешируем профиль для подэкранов
if (location.hash.startsWith("#/podbor")) {
Podbor.mount(app);
return;
}
if (me.role === "manager") renderManager(me); if (me.role === "manager") renderManager(me);
else renderClient(me); else renderClient(me);
} catch (e) { } catch (e) {
@ -360,4 +372,16 @@ async function init() {
} }
} }
function routeByHash() {
if (location.hash.startsWith("#/podbor")) {
Podbor.mount(app);
} else {
// Главный экран по роли
const me = window.__zovMe;
if (!me) { init(); return; }
if (me.role === "manager") renderManager(me);
else renderClient(me);
}
}
init(); init();

View File

@ -45,4 +45,18 @@ const ICONS = {
chat: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`, chat: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
home: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>`, home: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>`,
arrow_left: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>`,
/* Категории техники — line, stroke 1.5 */
cat_hob: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8" cy="8" r="1.6"/><circle cx="16" cy="8" r="2.5"/><circle cx="8" cy="16" r="2.5"/><circle cx="16" cy="16" r="1.6"/></svg>`,
cat_fridge: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M5 10h14"/><path d="M9 5v3"/><path d="M9 14v3"/></svg>`,
cat_dw: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M4 7h16"/><circle cx="8" cy="5" r=".6"/><circle cx="11" cy="5" r=".6"/><path d="M9 11c0 4 3 4 3 8"/><path d="M15 11c0 4-3 4-3 8"/></svg>`,
cat_hood: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10 7 4h10l4 6"/><path d="M3 10v4h18v-4"/><path d="M9 18v3"/><path d="M15 18v3"/></svg>`,
cat_oven: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><circle cx="7" cy="6" r=".6"/><circle cx="11" cy="6" r=".6"/><path d="M16 6h2"/><circle cx="12" cy="15" r="3"/></svg>`,
cat_microwave: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M15 5v14"/><circle cx="18" cy="9" r=".6"/><path d="M18 13v3"/><path d="M5 9h7"/><path d="M5 13h5"/></svg>`,
cat_coffee: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><path d="M6 2v3"/><path d="M10 2v3"/><path d="M14 2v3"/></svg>`,
cat_washer: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><circle cx="12" cy="14" r="5"/><circle cx="8" cy="6" r=".6"/><circle cx="11" cy="6" r=".6"/><path d="M14 6h3"/><path d="M9 14a3 3 0 0 1 6 0"/></svg>`,
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2"><path d="M20 6 9 17l-5-5"/></svg>`,
}; };

View File

@ -0,0 +1,101 @@
/* ============================================================
Подбор техники статические данные (адаптация 02_Чек-лист_клиенту.html)
============================================================ */
const PODBOR_CATEGORIES = [
{ key: "fridge", icon: "cat_fridge", label: "Холодильник" },
{ key: "hob", icon: "cat_hob", label: "Варочная панель" },
{ key: "oven", icon: "cat_oven", label: "Духовой шкаф" },
{ key: "dw", icon: "cat_dw", label: "Посудомоечная" },
{ key: "hood", icon: "cat_hood", label: "Вытяжка" },
{ key: "microwave", icon: "cat_microwave", label: "Микроволновка" },
{ key: "coffee", icon: "cat_coffee", label: "Кофемашина" },
{ key: "washer", icon: "cat_washer", label: "Стиральная машина" },
];
const PODBOR_BUDGET_TIERS = [
{ key: "premium", label: "Премиум", hint: "лучшее без оглядки на цену" },
{ key: "middle", label: "Средний", hint: "разумный баланс цена/функции" },
{ key: "budget", label: "Бюджет", hint: "только нужное" },
];
const PODBOR_FAMILY = [
{ key: "single", label: "1 взрослый" },
{ key: "couple", label: "Пара" },
{ key: "family", label: "Семья с детьми" },
{ key: "multigen", label: "2+ поколения" },
];
const PODBOR_COOKING = [
{ key: "daily", label: "Ежедневно" },
{ key: "weekly", label: "35 раз в неделю" },
{ key: "rare", label: "По выходным или реже" },
];
const PODBOR_INFRA = {
stove: [
{ key: "induction", label: "Индукция / 380 В" },
{ key: "el_220", label: "Электрика 220 В" },
{ key: "gas", label: "Газ" },
{ key: "any", label: "Не знаю / любой" },
],
vent: [
{ key: "shaft", label: "Шахта вентиляции есть" },
{ key: "no_shaft", label: "Только рециркуляция" },
{ key: "unknown", label: "Не знаю" },
],
};
const PODBOR_TECHNIQUES = [
{ key: "bake", label: "Выпечка" },
{ key: "steam", label: "На пару" },
{ key: "grill", label: "Гриль" },
{ key: "wok", label: "Wok / стир-фрай" },
{ key: "low_t", label: "Низкотемпературное" },
{ key: "smart", label: "Умные режимы / Smart" },
];
/* Бренды для каждой категории для чипов с тирами.
Сокращённый набор; полный список можно расширить из исходного HTML. */
const PODBOR_BRANDS = {
fridge: {
premium: ["Liebherr", "Miele", "Sub-Zero", "V-ZUG"],
middle: ["Bosch", "Siemens", "Samsung", "LG"],
budget: ["Indesit", "Beko", "Hotpoint"],
},
hob: {
premium: ["Miele", "Gaggenau", "AEG"],
middle: ["Bosch", "Siemens", "Electrolux", "Hansa"],
budget: ["Hotpoint", "Beko", "Indesit"],
},
oven: {
premium: ["Miele", "Gaggenau", "Neff"],
middle: ["Bosch", "Siemens", "Electrolux", "AEG"],
budget: ["Hansa", "Beko", "Hotpoint"],
},
dw: {
premium: ["Miele", "Asko", "V-ZUG"],
middle: ["Bosch", "Siemens", "Electrolux"],
budget: ["Hansa", "Beko", "Indesit"],
},
hood: {
premium: ["Miele", "Falmec", "Faber"],
middle: ["Bosch", "Siemens", "Elica"],
budget: ["Hansa", "Hotpoint", "Maunfeld"],
},
microwave: {
premium: ["Miele", "Neff"],
middle: ["Bosch", "Siemens", "Samsung", "LG"],
budget: ["Whirlpool", "Hansa", "Beko"],
},
coffee: {
premium: ["Miele", "Jura", "De'Longhi PrimaDonna"],
middle: ["De'Longhi", "Saeco", "Bosch"],
budget: ["Krups", "Philips"],
},
washer: {
premium: ["Miele", "Asko", "V-ZUG"],
middle: ["Bosch", "Siemens", "Samsung", "LG"],
budget: ["Indesit", "Hotpoint", "Beko"],
},
};

434
miniapp/assets/podbor.css Normal file
View File

@ -0,0 +1,434 @@
/* ============================================================
Подбор техники стили (Editorial Calm)
============================================================ */
/* ----- Header ----- */
.podbor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--s4);
}
.podbor-back {
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: var(--ink);
cursor: pointer;
}
.podbor-back svg { width: 20px; height: 20px; }
.podbor-title {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
/* ----- Progress ----- */
.podbor-progress {
margin-bottom: var(--s5);
}
.podbor-progress-bar {
height: 2px;
background: var(--line);
border-radius: var(--r-pill);
overflow: hidden;
margin-bottom: 6px;
}
.podbor-progress-bar .bar {
height: 100%;
background: var(--accent-2);
border-radius: inherit;
transition: width 0.3s ease;
}
.podbor-progress-meta {
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.podbor-progress-meta .num { color: var(--ink); }
/* ----- Step container ----- */
.podbor-screen { animation: fadeIn 0.2s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.podbor-step {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.display-title {
font-family: var(--font-display);
font-style: italic;
font-size: 32px;
font-weight: 400;
line-height: 1.05;
letter-spacing: -0.02em;
color: var(--ink);
margin: 0;
}
.display-title .accent { color: var(--accent-2); }
.lede {
font-size: 14.5px;
line-height: 1.45;
color: var(--ink-2);
margin: 0;
}
/* ----- Form basics ----- */
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-label {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.field input,
.field textarea,
.field-inline input {
font-family: var(--font-ui);
font-size: 15px;
color: var(--ink);
background: var(--card);
border: 1px solid var(--line-strong);
border-radius: var(--r-btn);
padding: 11px 12px;
outline: none;
transition: border-color 0.12s;
width: 100%;
}
.field input:focus,
.field textarea:focus,
.field-inline input:focus { border-color: var(--accent-2); }
.field textarea { resize: vertical; min-height: 64px; font-family: var(--font-ui); }
.form-row { display: flex; flex-direction: column; gap: var(--s2); }
.form-row.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s2); }
/* ----- Block grouping ----- */
.block {
background: var(--card);
border: 1px solid var(--line-strong);
border-radius: var(--r-card);
padding: var(--s4);
display: flex;
flex-direction: column;
gap: var(--s3);
}
.block-head {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.hint {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--muted);
}
/* ----- Categories grid ----- */
.cat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s2);
}
.cat-card {
background: var(--card);
border: 1px solid var(--line-strong);
border-radius: var(--r-card);
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
position: relative;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
min-height: 84px;
}
.cat-card:active { background: var(--paper-2); }
.cat-card.active {
border-color: var(--ink);
background: var(--paper-2);
}
.cat-card .cat-icon {
width: 26px; height: 26px;
display: grid; place-items: center;
color: var(--ink);
}
.cat-card .cat-icon svg { width: 22px; height: 22px; stroke-width: 1.4; }
.cat-card .cat-label {
font-family: var(--font-ui);
font-size: 13.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ink);
}
.cat-card .cat-check {
position: absolute;
top: 10px; right: 10px;
width: 18px; height: 18px;
background: var(--accent-2);
color: var(--paper);
border-radius: var(--r-pill);
display: grid; place-items: center;
}
.cat-card .cat-check svg { width: 12px; height: 12px; stroke-width: 2.5; }
/* ----- Niches ----- */
.niche-list { display: flex; flex-direction: column; gap: var(--s2); }
.niche-row { display: flex; flex-direction: column; gap: 4px; }
.niche-label {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 500;
color: var(--ink-2);
}
.niche-inputs { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; }
.niche-inputs input {
font-family: var(--font-mono);
font-size: 13px;
padding: 9px 10px;
text-align: center;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-tag);
color: var(--ink);
}
.budget-list { display: flex; flex-direction: column; gap: var(--s2); }
.field-inline {
display: flex; align-items: center; justify-content: space-between; gap: var(--s3);
font-family: var(--font-ui); font-size: 14px; color: var(--ink-2);
}
.field-inline input {
width: 130px; padding: 8px 10px; font-family: var(--font-mono); font-size: 13px;
background: var(--paper); border: 1px solid var(--line); border-radius: var(--r-tag);
text-align: right; color: var(--ink);
}
/* ----- Option chips ----- */
.opt-list { display: flex; flex-wrap: wrap; gap: 6px; }
.opt {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 500;
padding: 8px 12px;
border-radius: var(--r-pill);
border: 1px solid var(--line-strong);
background: var(--paper);
color: var(--ink-2);
cursor: pointer;
transition: all 0.12s;
}
.opt:active { transform: scale(0.97); }
.opt.on {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
/* ----- Brand chips ----- */
.tier-row { display: flex; flex-direction: column; gap: 6px; padding: 8px 0; border-top: 1px solid var(--line); }
.tier-row:first-child { border-top: none; padding-top: 0; }
.tier-label {
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.brand-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
font-family: var(--font-ui);
font-size: 12.5px;
font-weight: 500;
padding: 6px 10px;
border-radius: var(--r-tag);
border: 1px solid var(--line-strong);
background: var(--paper);
color: var(--muted);
cursor: pointer;
transition: all 0.12s;
position: relative;
}
.chip.status-preferred {
background: var(--accent-2);
color: var(--paper);
border-color: var(--accent-2);
font-weight: 600;
}
.chip.status-preferred::before { content: "★ "; }
.chip.status-acceptable {
background: var(--paper-2);
color: var(--ink);
border-color: var(--accent-2);
}
.chip.status-acceptable::before { content: "✓ "; }
/* ----- Summary ----- */
.summary-block { gap: 8px; }
.summary-block .kv {
display: flex; justify-content: space-between; align-items: baseline;
padding: 6px 0;
border-bottom: 1px solid var(--line);
font-family: var(--font-ui);
font-size: 14px;
}
.summary-block .kv:last-child { border-bottom: none; }
.summary-block .kv span {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.summary-block .kv strong { font-weight: 600; color: var(--ink); }
/* ----- CTA / buttons ----- */
.podbor-cta-row {
display: flex; gap: var(--s2);
margin-top: var(--s3);
}
.btn-primary, .btn-secondary {
flex: 1;
font-family: var(--font-ui);
font-size: 14.5px;
font-weight: 600;
letter-spacing: -0.01em;
padding: 12px var(--s4);
border-radius: var(--r-btn);
cursor: pointer;
transition: opacity 0.12s, background 0.12s;
}
.btn-primary {
background: var(--ink);
color: var(--paper);
}
.btn-primary:active { opacity: 0.85; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-secondary {
background: transparent;
color: var(--ink);
border: 1px solid var(--line-strong);
}
.btn-secondary:active { background: var(--paper-2); }
/* ----- Submit result ----- */
.submit-result { margin-top: var(--s3); }
.submit-result .success {
display: flex; align-items: center; gap: var(--s3);
background: var(--paper-2);
border: 1px solid var(--accent-2);
border-radius: var(--r-card);
padding: var(--s4);
}
.submit-result .success-icon {
width: 36px; height: 36px;
background: var(--accent-2);
color: var(--paper);
border-radius: var(--r-pill);
display: grid; place-items: center;
flex-shrink: 0;
}
.submit-result .success-icon svg { width: 20px; height: 20px; stroke-width: 2.5; }
.submit-result .success-title {
font-family: var(--font-ui);
font-size: 15px;
font-weight: 600;
color: var(--ink);
}
.submit-result .success-sub {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--muted);
margin-top: 2px;
}
.submit-result .error {
background: rgba(192, 57, 43, 0.08);
border: 1px solid rgba(192, 57, 43, 0.30);
color: #8C3F1E;
border-radius: var(--r-card);
padding: var(--s3) var(--s4);
font-size: 14px;
}
.spinner-inline {
display: inline-block;
width: 14px; height: 14px;
border: 1.5px solid rgba(251,247,240,0.3);
border-top-color: var(--paper);
border-radius: var(--r-pill);
animation: spin 0.7s linear infinite;
margin-right: 6px;
vertical-align: -2px;
}
.empty { text-align: center; color: var(--muted); padding: var(--s7) 0; }

519
miniapp/assets/podbor.js Normal file
View File

@ -0,0 +1,519 @@
/* ============================================================
Подбор техники 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(); } };
})();

View File

@ -12,7 +12,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260509j"> <link rel="stylesheet" href="assets/styles.css?v=20260509k">
<link rel="stylesheet" href="assets/podbor.css?v=20260509k">
</head> </head>
<body> <body>
<main id="app"> <main id="app">
@ -20,7 +21,9 @@
<div class="spinner"></div> <div class="spinner"></div>
</div> </div>
</main> </main>
<script src="assets/icons.js?v=20260509j"></script> <script src="assets/icons.js?v=20260509k"></script>
<script src="assets/app.js?v=20260509j"></script> <script src="assets/podbor.config.js?v=20260509k"></script>
<script src="assets/podbor.js?v=20260509k"></script>
<script src="assets/app.js?v=20260509k"></script>
</body> </body>
</html> </html>