miniapp: 4 UX fixes from user feedback

1. PHONE NORMALIZATION
   - On blur (or before submit): '9001234567' -> '+7 900 123-45-67'
   - Handles 8XXX, 7XXX, +7XXX, 10-digit mobile prefixes
   - Leaves untouched if not Russian-looking number

2. BRAND LIST FOR RF 2026
   - PODBOR_SINGLE_BRAND_OPTIONS updated with realistic 2026 brands
   - Promoted: Haier, Korting, Midea, Hisense, Бирюса, Атлант, Pozis, DEXP
   - Bosch/Siemens marked with ⚠ (parallel-import)
   - Miele/Liebherr/Smeg also marked ⚠
   - PODBOR_BRANDS per-category fully refreshed

3. BUDGET ADAPTIVE HINTS
   - Hints now scale by selected categories share of full kitchen
   - Just fridge picked → 'Средний' shows ~88-175 тыс instead of 350-700к
   - Full 8 categories → original 350-700к
   - PODBOR_BUDGET_SHARES + PODBOR_BUDGET_RANGES constants

4. INFRA STEP CONDITIONAL
   - Stove power question only shown if hob category picked
   - Vent question only shown if hood category picked
   - If neither → step auto-skips to summary (with brief notice)
   - Summary 'Назад' button respects skip — goes to strategy if needed
This commit is contained in:
wasrusgen 2026-05-11 14:25:25 +03:00
parent 44281b1e07
commit 80580db446
3 changed files with 181 additions and 73 deletions

View File

@ -50,30 +50,56 @@ const PODBOR_BRAND_STRATEGY = [
{ key: "different", label: "Разные марки по категориям", hint: "соберём оптимальный микс" },
];
/* Бренды, у которых есть полная линейка кухонной техники (для single-mode) */
/* Бренды, у которых есть полная линейка кухонной техники, реально доступные в РФ (2026).
tier: premium / middle / budget · note: "available" | "parallel" (параллельный импорт). */
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 },
// Премиум — официально или через параллельный импорт
{ key: "miele", label: "Miele", tier: "premium", note: "parallel" },
{ key: "asko", label: "Asko", tier: "premium", note: "available" },
{ key: "smeg", label: "Smeg", tier: "premium", note: "parallel" },
{ key: "gorenje", label: "Gorenje", tier: "premium", note: "available" },
// Средний — реально работающие бренды
{ key: "haier", label: "Haier", tier: "middle", note: "available" },
{ key: "samsung", label: "Samsung", tier: "middle", note: "available" },
{ key: "lg", label: "LG", tier: "middle", note: "available" },
{ key: "korting", label: "Körting", tier: "middle", note: "available" },
{ key: "midea", label: "Midea", tier: "middle", note: "available" },
{ key: "bosch", label: "Bosch ⚠", tier: "middle", note: "parallel" },
{ key: "siemens", label: "Siemens ⚠", tier: "middle", note: "parallel" },
// Бюджет — российские/китайские
{ key: "biryusa", label: "Бирюса", tier: "budget", note: "available" },
{ key: "atlant", label: "Атлант", tier: "budget", note: "available" },
{ key: "pozis", label: "Pozis", tier: "budget", note: "available" },
{ key: "hisense", label: "Hisense", tier: "budget", note: "available" },
{ key: "hansa", label: "Hansa", tier: "budget", note: "available" },
{ key: "dexp", label: "DEXP", tier: "budget", note: "available" },
{ key: "ai_pick", label: "Пусть AI выберет под бюджет", recommended: true },
];
/* Доля бюджета каждой категории от полного комплекта (для адаптивных вилок). */
const PODBOR_BUDGET_SHARES = {
fridge: 25, hob: 12, oven: 15, dw: 10,
hood: 8, microwave: 5, coffee: 15, washer: 10,
};
/* Базовые вилки для ПОЛНОГО комплекта 8 категорий (в тыс. ).
Адаптируются по выбранным категориям через PODBOR_BUDGET_SHARES. */
const PODBOR_BUDGET_RANGES = {
luxe: { from: 1500, to: 3000 }, // от 1.5М
premium: { from: 700, to: 1500 },
middle: { from: 350, to: 700 },
budget: { from: 100, to: 350 },
};
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: "ввести от-до по категориям" },
{ key: "luxe", label: "Люкс", desc: "лучшее без оглядки на цену" },
{ key: "premium", label: "Премиум", desc: "топовые модели · все опции" },
{ key: "middle", label: "Средний", desc: "оптимальный баланс · цена/функции", recommended: true },
{ key: "budget", label: "Бюджет", desc: "только нужное" },
{ key: "exact", label: "Точные цифры", desc: "вилки от-до по каждой категории" },
];
const PODBOR_PICK_STRATEGIES = [
@ -635,47 +661,47 @@ const PODBOR_PARAMS = {
},
};
/* Бренды для каждой категории для чипов с тирами.
Сокращённый набор; полный список можно расширить из исходного HTML. */
/* Бренды по категориям (актуально на 2026, РФ).
параллельный импорт, остальные официально доступны. */
const PODBOR_BRANDS = {
fridge: {
premium: ["Liebherr", "Miele", "Sub-Zero", "V-ZUG"],
middle: ["Bosch", "Siemens", "Samsung", "LG"],
budget: ["Indesit", "Beko", "Hotpoint"],
premium: ["Miele ⚠", "Liebherr ⚠", "Asko", "Gorenje"],
middle: ["Haier", "Samsung", "LG", "Korting", "Bosch ⚠", "Siemens ⚠"],
budget: ["Бирюса", "Атлант", "Pozis", "Hisense", "Indesit", "Hansa"],
},
hob: {
premium: ["Miele", "Gaggenau", "AEG"],
middle: ["Bosch", "Siemens", "Electrolux", "Hansa"],
budget: ["Hotpoint", "Beko", "Indesit"],
premium: ["Miele ⚠", "Asko", "Gorenje", "Smeg ⚠"],
middle: ["Korting", "Haier", "Midea", "Bosch ⚠", "Siemens ⚠"],
budget: ["Hansa", "Hisense", "DEXP", "Дарина"],
},
oven: {
premium: ["Miele", "Gaggenau", "Neff"],
middle: ["Bosch", "Siemens", "Electrolux", "AEG"],
budget: ["Hansa", "Beko", "Hotpoint"],
premium: ["Miele ⚠", "Asko", "Gorenje", "Smeg ⚠"],
middle: ["Korting", "Haier", "Midea", "Samsung", "Bosch ⚠"],
budget: ["Hansa", "Hisense", "DEXP", "Дарина"],
},
dw: {
premium: ["Miele", "Asko", "V-ZUG"],
middle: ["Bosch", "Siemens", "Electrolux"],
budget: ["Hansa", "Beko", "Indesit"],
premium: ["Miele", "Asko", "Gorenje"],
middle: ["Haier", "Midea", "Korting", "Bosch ⚠"],
budget: ["Hansa", "Hisense", "Indesit"],
},
hood: {
premium: ["Miele", "Falmec", "Faber"],
middle: ["Bosch", "Siemens", "Elica"],
budget: ["Hansa", "Hotpoint", "Maunfeld"],
premium: ["Miele", "Falmec", "Faber ⚠", "Gorenje"],
middle: ["Korting", "Maunfeld", "Elikor", "Haier"],
budget: ["Hansa", "Hisense", "DEXP", "Krona"],
},
microwave: {
premium: ["Miele", "Neff"],
middle: ["Bosch", "Siemens", "Samsung", "LG"],
budget: ["Whirlpool", "Hansa", "Beko"],
premium: ["Miele ⚠", "Asko"],
middle: ["Samsung", "LG", "Haier", "Midea", "Bosch ⚠"],
budget: ["Hansa", "Hisense", "DEXP", "Polaris"],
},
coffee: {
premium: ["Miele", "Jura", "De'Longhi PrimaDonna"],
middle: ["De'Longhi", "Saeco", "Bosch"],
budget: ["Krups", "Philips"],
premium: ["Miele ⚠", "Jura ⚠", "Saeco ⚠"],
middle: ["De'Longhi ⚠", "Philips ⚠", "Polaris", "Bork ⚠"],
budget: ["Polaris", "Redmond", "Kitfort"],
},
washer: {
premium: ["Miele", "Asko", "V-ZUG"],
middle: ["Bosch", "Siemens", "Samsung", "LG"],
budget: ["Indesit", "Hotpoint", "Beko"],
premium: ["Miele", "Asko", "Gorenje"],
middle: ["Haier", "Samsung", "LG", "Korting", "Bosch ⚠"],
budget: ["Атлант", "Indesit", "Hansa", "Hisense"],
},
};

View File

@ -892,8 +892,26 @@ const Podbor = (function () {
function renderBudget() {
const bp = state.budget_preset || "";
// Считаем суммарную долю выбранных категорий от полного комплекта
const share = (state.categories || []).reduce(
(s, c) => s + (PODBOR_BUDGET_SHARES[c] || 0), 0
) / 100;
const shareSafe = share > 0 ? share : 1;
// Подмешиваем hint с реальной вилкой для выбранных категорий
const presetsWithRange = PODBOR_BUDGET_PRESETS.map(o => {
if (o.key === "exact") return { ...o, hint: o.desc };
const r = PODBOR_BUDGET_RANGES[o.key];
if (!r) return { ...o, hint: o.desc };
const from = Math.round(r.from * shareSafe);
const to = Math.round(r.to * shareSafe);
const label = from >= 1000 ? `${(from/1000).toFixed(1)}${(to/1000).toFixed(1)}М` : `${from}${to} тыс ₽`;
return { ...o, hint: `${label} · ${o.desc}` };
});
const presetGrid = renderPinCards(
PODBOR_BUDGET_PRESETS,
presetsWithRange,
o => (bp === o.key ? "on" : ""),
key => { update({ budget_preset: key }); render(); }
);
@ -1010,27 +1028,58 @@ const Podbor = (function () {
/* ===================== Step: infra ===================== */
function renderInfra() {
const cats = state.categories || [];
const askStove = cats.includes("hob");
const askVent = cats.includes("hood");
// Если ни одна из релевантных категорий не выбрана — пропускаем шаг
if (!askStove && !askVent) {
// Автопереход на summary через микропаузу (чтобы пользователь увидел)
setTimeout(() => { go("summary"); }, 50);
return el(`
<section class="podbor-step">
<p class="lede" style="text-align:center;padding:30px;">
Инфраструктурные вопросы для выбранных категорий не требуются переходим к итогу...
</p>
</section>
`);
}
const stoveBlock = askStove ? `
<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>
` : "";
const ventBlock = askVent ? `
<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 class="hint">Если «Нет» менеджер закладывает угольный фильтр. Если «Да» заранее планируем выводы.</div>
</div>
` : "";
const lede = (askStove && askVent)
? "Газ или электрика — определит тип варочной. Подключение вытяжки — нужны ли выводы или угольный фильтр."
: askStove
? "Газ или электрика — определит тип варочной (индукция / стеклокерамика / газ)."
: "Подключение вытяжки — нужны ли выводы в вентшахту или угольный фильтр.";
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 class="hint">Если «Нет» менеджер закладывает угольный фильтр. Если «Да» заранее планируем выводы.</div>
</div>
<p class="lede">${lede}</p>
${stoveBlock}
${ventBlock}
<div class="podbor-cta-row">
<button class="btn-secondary" data-go="strategy">Назад</button>
<button class="btn-primary" data-go="summary">Дальше</button>
@ -1103,7 +1152,7 @@ const Podbor = (function () {
</label>
<div class="podbor-cta-row">
<button class="btn-secondary" data-go="infra">Назад</button>
<button class="btn-secondary" id="summaryBack">Назад</button>
<button class="btn-primary" id="submitBtn">Отправить · AI подберёт</button>
</div>
@ -1112,6 +1161,12 @@ const Podbor = (function () {
`);
bindInputs(node);
bindNav(node);
// Кнопка "Назад" — обходим infra если она авто-пропускается
node.querySelector("#summaryBack").addEventListener("click", () => {
const cats = state.categories || [];
const goTo = (cats.includes("hob") || cats.includes("hood")) ? "infra" : "strategy";
go(goTo);
});
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
return node;
}
@ -1131,6 +1186,11 @@ const Podbor = (function () {
}
try {
// Финальная нормализация телефона перед отправкой
const normPhone = normalizePhone(state.client_phone || "");
if (normPhone && normPhone !== state.client_phone) {
update({ client_phone: normPhone });
}
const res = await fetch(`${BACKEND_URL}/api/podbor`, {
method: "POST",
body: JSON.stringify({
@ -1335,9 +1395,31 @@ const Podbor = (function () {
inp.addEventListener("input", e => {
update({ [e.target.dataset.bind]: e.target.value });
});
// Нормализация телефона на blur
if (inp.dataset.bind === "client_phone") {
inp.addEventListener("blur", e => {
const normalized = normalizePhone(e.target.value);
if (normalized && normalized !== e.target.value) {
e.target.value = normalized;
update({ client_phone: normalized });
}
});
}
});
}
/* Приводим телефон к единому формату +7 XXX XXX-XX-XX.
Принимает: 8XXXXXXXXXX, 7XXXXXXXXXX, +7XXXXXXXXXX, 9XXXXXXXXX (без префикса). */
function normalizePhone(raw) {
if (!raw) return "";
const digits = raw.replace(/\D/g, "");
let d = digits;
if (d.length === 11 && d.startsWith("8")) d = "7" + d.slice(1);
if (d.length === 10 && d.startsWith("9")) d = "7" + d; // мобильный без префикса
if (d.length !== 11 || !d.startsWith("7")) return raw.trim(); // не похоже на РФ-номер — не трогаем
return `+7 ${d.slice(1, 4)} ${d.slice(4, 7)}-${d.slice(7, 9)}-${d.slice(9, 11)}`;
}
function bindNav(node) {
node.querySelectorAll("[data-go]").forEach(b => {
b.addEventListener("click", () => go(b.dataset.go));

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=20260511e">
<link rel="stylesheet" href="assets/podbor.css?v=20260511e">
<link rel="stylesheet" href="assets/styles.css?v=20260511f">
<link rel="stylesheet" href="assets/podbor.css?v=20260511f">
</head>
<body>
<main id="app">
@ -21,10 +21,10 @@
<div class="spinner"></div>
</div>
</main>
<script src="assets/icons.js?v=20260511e"></script>
<script src="assets/podbor.config.js?v=20260511e"></script>
<script src="assets/podbor.picts.js?v=20260511e"></script>
<script src="assets/podbor.js?v=20260511e"></script>
<script src="assets/app.js?v=20260511e"></script>
<script src="assets/icons.js?v=20260511f"></script>
<script src="assets/podbor.config.js?v=20260511f"></script>
<script src="assets/podbor.picts.js?v=20260511f"></script>
<script src="assets/podbor.js?v=20260511f"></script>
<script src="assets/app.js?v=20260511f"></script>
</body>
</html>