diff --git a/miniapp/assets/podbor.config.js b/miniapp/assets/podbor.config.js index 4ad2fb4..b48ffbc 100644 --- a/miniapp/assets/podbor.config.js +++ b/miniapp/assets/podbor.config.js @@ -42,6 +42,49 @@ const PODBOR_PRIORITIES = [ { key: "service", label: "Сервис и гарантия" }, ]; +/* === Новая структура: бренд-стратегия / бюджет / стратегия подбора === */ + +const PODBOR_BRAND_STRATEGY = [ + { key: "ai", label: "Пусть AI решит", hint: "оптимально под бюджет и стратегию", recommended: true }, + { key: "single", label: "Одна марка на всю кухню", hint: "моноблочный комплект, премиум-сценарий" }, + { key: "different", label: "Разные марки по категориям", hint: "соберём оптимальный микс" }, +]; + +/* Бренды, у которых есть полная линейка кухонной техники (для single-mode) */ +const PODBOR_SINGLE_BRAND_OPTIONS = [ + { key: "miele", label: "Miele", tier: "premium" }, + { key: "gaggenau", label: "Gaggenau", tier: "premium" }, + { key: "asko", label: "Asko", tier: "premium" }, + { key: "v_zug", label: "V-ZUG", tier: "premium" }, + { key: "neff", label: "Neff", tier: "middle" }, + { key: "bosch", label: "Bosch", tier: "middle" }, + { key: "siemens", label: "Siemens", tier: "middle" }, + { key: "electrolux", label: "Electrolux", tier: "middle" }, + { key: "aeg", label: "AEG", tier: "middle" }, + { key: "samsung", label: "Samsung", tier: "middle" }, + { key: "lg", label: "LG", tier: "middle" }, + { key: "hansa", label: "Hansa", tier: "budget" }, + { key: "beko", label: "Beko", tier: "budget" }, + { key: "ai_pick", label: "Пусть AI выберет под бюджет", recommended: true }, +]; + +const PODBOR_BUDGET_PRESETS = [ + { key: "luxe", label: "Люкс", hint: "от 1.5М ₽ за весь комплект" }, + { key: "premium", label: "Премиум", hint: "700к – 1.5М ₽" }, + { key: "middle", label: "Средний", hint: "350к – 700к ₽", recommended: true }, + { key: "budget", label: "Бюджет", hint: "до 350к ₽" }, + { key: "exact", label: "Точные цифры", hint: "ввести от-до по категориям" }, +]; + +const PODBOR_PICK_STRATEGIES = [ + { key: "reviews", label: "Лучшее по отзывам", hint: "топ по рейтингам пользователей" }, + { key: "balance", label: "Цена / качество", hint: "оптимальный баланс", recommended: true }, + { key: "premium_brand", label: "Топ-бренды премиум", hint: "Miele · Gaggenau · Sub-Zero" }, + { key: "cheap", label: "Самое доступное", hint: "надёжный минимум" }, + { key: "tech", label: "Современные технологии", hint: "Wi-Fi · инверторы · пар" }, + { key: "style", label: "Стилевая согласованность", hint: "единый дизайн-язык всей техники" }, +]; + /* Параметры по категориям. ---------------------------------------------------------- Новая схема (иерархический wizard): diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index 4b6579d..6beb61b 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -486,6 +486,24 @@ .chip.status-acceptable::before { content: "✓ "; } +/* Состояние avoid — приглушённый, перечёркнутый */ +.chip.status-avoid { + background: #F5E1DC; + border-color: #C7705A; + color: #8A3E2A; + text-decoration: line-through; + opacity: 0.85; +} +.chip.status-avoid::before { content: "✗ "; text-decoration: none; display: inline-block; } + +/* Disabled кнопка */ +.btn-primary[disabled], +.btn-secondary[disabled] { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + /* ----- Summary ----- */ .summary-block { gap: 8px; } .summary-block .kv { diff --git a/miniapp/assets/podbor.js b/miniapp/assets/podbor.js index a6b53cd..65e3938 100644 --- a/miniapp/assets/podbor.js +++ b/miniapp/assets/podbor.js @@ -3,8 +3,9 @@ ============================================================ */ const Podbor = (function () { - const STORAGE_KEY = "zov-podbor-v3"; - const STEPS = ["intro", "categories", "detail", "pricing", "infra", "priorities", "brands", "summary"]; + 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"; @@ -16,7 +17,11 @@ const Podbor = (function () { function loadState() { try { const raw = localStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw); + if (raw) { + const parsed = JSON.parse(raw); + // Мерж с дефолтами для совместимости с новыми полями + return { ...defaultState(), ...parsed }; + } } catch (e) {} return defaultState(); } @@ -27,11 +32,14 @@ const Podbor = (function () { client_phone: "", address: "", categories: [], // ['fridge','hob',...] - per_cat: {}, // { fridge: { params: {type:'sbs',...}, features: ['nofrost'], notes: '' }, ... } - price_ranges: {}, // { fridge: { from: 50000, to: 120000 }, ... } + 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: "" }, - priorities: [], // ['balance','reviews',...] - brands: {}, // { fridge: {Bosch:'preferred',...}, ... } notes: "", }; } @@ -78,10 +86,10 @@ const Podbor = (function () { case "intro": screen.appendChild(renderIntro()); break; case "categories": screen.appendChild(renderCategories()); break; case "detail": screen.appendChild(renderDetail()); break; - case "pricing": screen.appendChild(renderPricing()); 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 "priorities": screen.appendChild(renderPriorities()); break; - case "brands": screen.appendChild(renderBrands()); break; case "summary": screen.appendChild(renderSummary()); break; } } @@ -148,12 +156,11 @@ const Podbor = (function () { 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_LABELS[idx] || ""}${idx + 1}/${total}
`); @@ -292,7 +299,7 @@ const Podbor = (function () {
${cards}
- +
`); @@ -700,77 +707,263 @@ const Podbor = (function () { return node; } - /* ===================== Step: pricing (ценовой коридор по категориям) ===================== */ + function formatRub(n) { + if (!n) return "—"; + return Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); + } - 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] || {}; + /* Универсальный рендер пин-карточек (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 ` -
-
${cat.label}
-
- - - - -
-
+ `; }).join(""); + const wrap = el(`
${html}
`); + wrap.querySelectorAll(".wiz-card").forEach(btn => { + btn.addEventListener("click", () => { + onClick(btn.dataset.key); + }); + }); + return wrap; + } - const totalLine = (totalFrom || totalTo) - ? `
Итого: ${formatRub(totalFrom)} — ${formatRub(totalTo)} ₽
` - : `
Сумма посчитается автоматически
`; + /* ===================== 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(`
-

Ценовой
коридор

-

«От — До» по каждой категории. 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(); + 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 formatRub(n) { - if (!n) return "—"; - return Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); + function tierLabel(tier) { + return tier === "premium" ? "премиум" : tier === "middle" ? "средний" : tier === "budget" ? "бюджет" : ""; + } + + /* ===================== Step: budget (пресет или точные цифры) ===================== */ + + function renderBudget() { + const bp = state.budget_preset || ""; + const presetGrid = renderPinCards( + PODBOR_BUDGET_PRESETS, + 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 ===================== */ @@ -779,7 +972,7 @@ const Podbor = (function () { const node = el(`

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

-

Газ или электрика — определит тип варочной (индукция / стеклокерамика / газ). Подключение вытяжки — нужны ли выводы или угольный фильтр.

+

Газ или электрика — определит тип варочной. Подключение вытяжки — нужны ли выводы или угольный фильтр.

Подключение варочной
@@ -798,8 +991,8 @@ const Podbor = (function () {
Если «Нет» — менеджер закладывает угольный фильтр. Если «Да» — заранее планируем выводы.
- - + +
`); @@ -813,110 +1006,40 @@ const Podbor = (function () { 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) + // Бренд-стратегия + 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(` @@ -926,10 +1049,11 @@ const Podbor = (function () {
Клиент${state.client_name || "—"}
Категорий${state.categories.length}
-
Ценовой коридор${totalRange}
+
Бренд${bsLabel}${brandDetail}
+
Бюджет${budgetLabel}
+
Стратегия${strategyLabels || "—"}
Подключение${PODBOR_INFRA.stove.find(f => f.key === state.infra.stove)?.label || "—"}
Вентиляция${PODBOR_INFRA.vent.find(f => f.key === state.infra.vent)?.label || "—"}
-
Приоритеты${priorityLabels || "—"}
- +
diff --git a/miniapp/index.html b/miniapp/index.html index 569c033..0cf2d35 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + +
@@ -21,10 +21,10 @@
- - - - - + + + + +