feat(podbor): drop niches; price-range from-to per cat; ventilation Y/N; priorities multi-select; brand tiers as color (no labels)

This commit is contained in:
wasrusgen 2026-05-09 15:10:23 +03:00
parent 571297c017
commit 129046de07
4 changed files with 241 additions and 171 deletions

View File

@ -19,19 +19,6 @@ const PODBOR_BUDGET_TIERS = [
{ 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 В" },
@ -40,19 +27,19 @@ const PODBOR_INFRA = {
{ key: "any", label: "Не знаю / любой" },
],
vent: [
{ key: "shaft", label: "Шахта вентиляции есть" },
{ key: "no_shaft", label: "Только рециркуляция" },
{ key: "unknown", label: "Не знаю" },
{ key: "yes", label: "Да — есть выводы в вентиляцию" },
{ key: "no", 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" },
const PODBOR_PRIORITIES = [
{ key: "balance", label: "Цена / качество" },
{ key: "reviews", label: "Отзывы" },
{ key: "popular", label: "Популярность бренда" },
{ key: "design", label: "Дизайн и цвет" },
{ key: "tech", label: "Технологичность" },
{ key: "service", label: "Сервис и гарантия" },
];
/* Бренды для каждой категории для чипов с тирами.

View File

@ -258,6 +258,72 @@
text-align: right; color: var(--ink);
}
/* ----- Price range (от — до по категориям) ----- */
.price-list { display: flex; flex-direction: column; gap: var(--s3); }
.price-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.price-label {
font-family: var(--font-ui);
font-size: 13.5px;
font-weight: 500;
color: var(--ink-2);
}
.price-inputs {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.price-inputs input {
flex: 1;
min-width: 0;
width: 100%;
font-family: var(--font-mono);
font-size: 13px;
padding: 8px 10px;
text-align: center;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-tag);
color: var(--ink);
}
.price-inputs .dash {
font-family: var(--font-mono);
color: var(--muted);
flex-shrink: 0;
}
.price-inputs .rub {
font-family: var(--font-mono);
font-size: 12px;
color: var(--muted);
flex-shrink: 0;
padding-left: 2px;
}
.price-total {
margin-top: var(--s2);
padding-top: var(--s2);
border-top: 1px solid var(--line);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--ink-2);
text-align: right;
}
.price-total strong { font-family: var(--font-ui); font-size: 14px; color: var(--ink); font-weight: 600; }
.price-total.muted { color: var(--muted); }
/* ----- Option chips ----- */
.opt-list { display: flex; flex-wrap: wrap; gap: 6px; }
@ -283,49 +349,86 @@
}
/* ----- 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; }
/* Тиры (Premium/Middle/Budget) различаются цветом, без явных текстовых ярлыков.
Внутренне храним tier для аналитики «температуры» клиента. */
.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;
padding: 6px 0;
border-top: 1px dashed var(--line);
}
.brand-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.brand-chips:first-of-type {
border-top: none;
padding-top: 0;
}
.chip {
font-family: var(--font-ui);
font-size: 12.5px;
font-weight: 500;
padding: 6px 10px;
padding: 6px 11px;
border-radius: var(--r-tag);
border: 1px solid var(--line-strong);
background: var(--paper);
color: var(--muted);
border: 1px solid;
cursor: pointer;
transition: all 0.12s;
position: relative;
}
.chip.status-preferred {
background: var(--accent-2);
/* Базовый цвет покоя по тирам — без слов, только тёплый/холодный оттенок */
.chip.tier-premium {
background: var(--paper);
color: var(--accent-2); /* walnut */
border-color: rgba(107, 74, 43, 0.35);
}
.chip.tier-middle {
background: var(--paper);
color: var(--ink-2);
border-color: var(--line-strong);
}
.chip.tier-budget {
background: var(--paper);
color: var(--muted);
border-color: var(--line);
}
/* Состояние preferred — заливка цветом тира */
.chip.tier-premium.status-preferred {
background: var(--accent-2); /* walnut */
color: var(--paper);
border-color: var(--accent-2);
font-weight: 600;
}
.chip.tier-middle.status-preferred {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
font-weight: 600;
}
.chip.tier-budget.status-preferred {
background: var(--muted);
color: var(--paper);
border-color: var(--muted);
font-weight: 600;
}
.chip.status-preferred::before { content: "★ "; }
/* Состояние acceptable — обводка цветом тира, прозрачная заливка */
.chip.status-acceptable {
background: var(--paper-2);
color: var(--ink);
border-color: var(--accent-2);
}
.chip.tier-premium.status-acceptable { border-color: var(--accent-2); color: var(--accent-2); }
.chip.tier-middle.status-acceptable { border-color: var(--ink); color: var(--ink); }
.chip.tier-budget.status-acceptable { border-color: var(--muted); color: var(--ink-2); }
.chip.status-acceptable::before { content: "✓ "; }
/* ----- Summary ----- */

View File

@ -3,8 +3,8 @@
============================================================ */
const Podbor = (function () {
const STORAGE_KEY = "zov-podbor-v1";
const STEPS = ["intro", "categories", "context", "infra", "scenario", "brands", "summary"];
const STORAGE_KEY = "zov-podbor-v2";
const STEPS = ["intro", "categories", "pricing", "infra", "priorities", "brands", "summary"];
let state = loadState();
let root = null;
@ -23,13 +23,11 @@ const Podbor = (function () {
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, ... }
categories: [], // ['fridge','hob',...]
price_ranges: {}, // { fridge: { from: 50000, to: 120000 }, ... }
infra: { stove: "", vent: "" },
scenario: { family: "", cooking: "", techniques: [], guests: "" },
brands: {}, // { fridge: {Bosch:'preferred', Liebherr:'preferred'}, ... }
priorities: [], // ['balance','reviews',...]
brands: {}, // { fridge: {Bosch:'preferred',...}, ... }
notes: "",
};
}
@ -72,9 +70,9 @@ const Podbor = (function () {
switch (currentStep) {
case "intro": screen.appendChild(renderIntro()); break;
case "categories": screen.appendChild(renderCategories()); break;
case "context": screen.appendChild(renderContext()); break;
case "pricing": screen.appendChild(renderPricing()); break;
case "infra": screen.appendChild(renderInfra()); break;
case "scenario": screen.appendChild(renderScenario()); break;
case "priorities": screen.appendChild(renderPriorities()); break;
case "brands": screen.appendChild(renderBrands()); break;
case "summary": screen.appendChild(renderSummary()); break;
}
@ -107,7 +105,7 @@ const Podbor = (function () {
const idx = STEPS.indexOf(currentStep);
const total = STEPS.length;
const pct = Math.round(((idx + 1) / total) * 100);
const labels = ["Старт", "Категории", "Контекст", "Инфра", "Сценарий", "Бренды", "Подбор"];
const labels = ["Старт", "Категории", "Цена", "Инфра", "Приоритеты", "Бренды", "Подбор"];
return el(`
<div class="podbor-progress">
<div class="podbor-progress-bar"><div class="bar" style="width:${pct}%"></div></div>
@ -124,7 +122,7 @@ const Podbor = (function () {
const node = el(`
<section class="podbor-step">
<h2 class="display-title">Подбор техники<br><span class="accent">для клиента</span></h2>
<p class="lede">7 коротких шагов. Указываем категории, бюджет, инфраструктуру и предпочтения. Дальше AI сам соберёт предложение.</p>
<p class="lede">7 коротких шагов. Категории, ценовой коридор, инфраструктура и предпочтения. AI соберёт предложение.</p>
<div class="form-row">
<label class="field">
@ -132,15 +130,11 @@ const Podbor = (function () {
<input type="text" data-bind="client_name" value="${state.client_name || ""}" placeholder="Например: А. Пестова">
</label>
</div>
<div class="form-row two-col">
<div class="form-row">
<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">
@ -189,86 +183,86 @@ const Podbor = (function () {
return node;
}
/* ===================== Step: context (ниши + бюджет по категориям) ===================== */
/* ===================== Step: pricing (ценовой коридор по категориям) ===================== */
function renderContext() {
const builtinCats = ["fridge", "hob", "oven", "dw"]; // встройка
const niches = builtinCats.filter(c => state.categories.includes(c)).map(c => {
function renderPricing() {
if (!state.categories.length) {
return el(`
<section class="podbor-step">
<div class="empty">Сначала выберите категории.</div>
<div class="podbor-cta-row">
<button class="btn-secondary" data-go="categories">Назад</button>
</div>
</section>
`);
}
// Подсчёт суммы коридоров
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 n = state.niches[c] || {};
const r = state.price_ranges[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 class="price-row">
<div class="price-label">${cat.label}</div>
<div class="price-inputs">
<input type="number" inputmode="numeric" data-price="${c}.from" value="${r.from || ""}" placeholder="от">
<span class="dash"></span>
<input type="number" inputmode="numeric" data-price="${c}.to" value="${r.to || ""}" placeholder="до">
<span class="rub"></span>
</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 totalLine = (totalFrom || totalTo)
? `<div class="price-total">Итого: <strong>${formatRub(totalFrom)}${formatRub(totalTo)} ₽</strong></div>`
: `<div class="price-total muted">Сумма посчитается автоматически</div>`;
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>
` : ""}
<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="price-list">${rows}</div>
${totalLine}
</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 => {
node.querySelectorAll("[data-price]").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 });
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(`
<section class="podbor-step">
<h2 class="display-title">Инфраструктура<br><span class="accent">кухни</span></h2>
<p class="lede">Газ или электрика главный вопрос для варочной. Шахта вентиляции для вытяжки.</p>
<p class="lede">Газ или электрика определит тип варочной (индукция / стеклокерамика / газ). Подключение вытяжки нужны ли выводы или угольный фильтр.</p>
<div class="block">
<div class="block-head">Подключение варочной</div>
<div class="opt-list">
@ -278,16 +272,17 @@ const Podbor = (function () {
</div>
</div>
<div class="block">
<div class="block-head">Вентиляция для вытяжки</div>
<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 class="hint">Если «Нет» менеджер закладывает угольный фильтр. Если «Да» заранее планируем выводы.</div>
</div>
<div class="podbor-cta-row">
<button class="btn-secondary" data-go="context">Назад</button>
<button class="btn-primary" data-go="scenario">Дальше</button>
<button class="btn-secondary" data-go="pricing">Назад</button>
<button class="btn-primary" data-go="priorities">Дальше</button>
</div>
</section>
`);
@ -301,60 +296,34 @@ const Podbor = (function () {
return node;
}
/* ===================== Step: scenario ===================== */
/* ===================== Step: priorities (что важно при выборе) ===================== */
function renderScenario() {
function renderPriorities() {
const node = el(`
<section class="podbor-step">
<h2 class="display-title">Сценарий<br><span class="accent">использования</span></h2>
<p class="lede">Семья с детьми готовит иначе, чем пара. AI учтёт это в подборе.</p>
<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="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>
${PODBOR_PRIORITIES.map(o => `
<button class="opt${(state.priorities || []).includes(o.key) ? " on" : ""}" data-pri="${o.key}">${o.label}</button>
`).join("")}
</div>
<div class="hint">Можно несколько · в порядке выбора</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 => {
node.querySelectorAll("[data-pri]").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 cur = state.priorities || [];
const key = b.dataset.pri;
const next = cur.includes(key) ? cur.filter(x => x !== key) : [...cur, key];
update({ scenario: { ...state.scenario, techniques: next } });
update({ priorities: next });
render();
});
});
@ -372,22 +341,20 @@ const Podbor = (function () {
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>
// Тиры остаются в данных (для аналитики «температуры» клиента),
// но визуально просто разный цветовой оттенок чипа — без явного ярлыка.
const tierGroup = (tier) => `
<div class="brand-chips brand-tier-${tier}">
${(brands[tier] || []).map(b => {
const status = catState[b] || "none";
return `<button class="chip tier-${tier} status-${status}" data-cat="${catKey}" data-brand="${b}" data-tier="${tier}">${b}</button>`;
}).join("")}
</div>
`;
return `
<div class="block">
<div class="block-head">${cat.label}</div>
<div class="hint"> Тап предпочтительно · Двойной допустимо · Третий снять</div>
${tierBlock("premium")}${tierBlock("middle")}${tierBlock("budget")}
${tierGroup("premium")}${tierGroup("middle")}${tierGroup("budget")}
</div>
`;
}).join("");
@ -395,10 +362,10 @@ const Podbor = (function () {
const node = el(`
<section class="podbor-step">
<h2 class="display-title">Бренды<br><span class="accent">по категориям</span></h2>
<p class="lede">Какие марки уважаете, какие допустимы. AI сначала пробует preferred.</p>
<p class="lede">Тап предпочтительно. Дабл допустимо. Третий снять. AI сначала пробует , потом .</p>
${blocks}
<div class="podbor-cta-row">
<button class="btn-secondary" data-go="scenario">Назад</button>
<button class="btn-secondary" data-go="priorities">Назад</button>
<button class="btn-primary" data-go="summary">Дальше</button>
</div>
</section>
@ -422,17 +389,30 @@ const Podbor = (function () {
/* ===================== 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(`
<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>${totalRange}</strong></div>
<div class="kv"><span>Подключение</span><strong>${PODBOR_INFRA.stove.find(f => f.key === state.infra.stove)?.label || ""}</strong></div>
<div class="kv"><span>Вентиляция</span><strong>${PODBOR_INFRA.vent.find(f => f.key === state.infra.vent)?.label || ""}</strong></div>
<div class="kv"><span>Приоритеты</span><strong>${priorityLabels || ""}</strong></div>
</div>
<label class="field">

View File

@ -12,8 +12,8 @@
<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">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260509l">
<link rel="stylesheet" href="assets/podbor.css?v=20260509l">
<link rel="stylesheet" href="assets/styles.css?v=20260509m">
<link rel="stylesheet" href="assets/podbor.css?v=20260509m">
</head>
<body>
<main id="app">
@ -21,9 +21,9 @@
<div class="spinner"></div>
</div>
</main>
<script src="assets/icons.js?v=20260509l"></script>
<script src="assets/podbor.config.js?v=20260509l"></script>
<script src="assets/podbor.js?v=20260509l"></script>
<script src="assets/app.js?v=20260509l"></script>
<script src="assets/icons.js?v=20260509m"></script>
<script src="assets/podbor.config.js?v=20260509m"></script>
<script src="assets/podbor.js?v=20260509m"></script>
<script src="assets/app.js?v=20260509m"></script>
</body>
</html>