feat(podbor iter2): per-category detail menu with primary params + accordion 'Подробнее' for tech features; updated AI prompt to require feature explanations

This commit is contained in:
wasrusgen 2026-05-09 15:21:05 +03:00
parent 129046de07
commit cc28984122
5 changed files with 698 additions and 24 deletions

View File

@ -494,25 +494,44 @@ function synthesizeManagerFromUser(user) {
const SYSTEM_PROMPT_PICKER = (
"Ты — эксперт-консультант по подбору кухонной техники для фабрики мебели «ЗОВ».\n" +
"Помогаешь менеджерам салонов быстро согласовать с клиентом комплект техники.\n\n" +
"Принципы:\n" +
"1. Физические ограничения важнее эстетики. Если ниша 600×1850×600 — не предлагай 700×2000×650.\n" +
"2. Уважай бюджет. Лимит в категории — не превышай >10%.\n" +
"3. Уважай предпочтения по брендам: сначала preferred (★), потом alternative (✓).\n" +
"4. Сценарий использования. Семья с детьми = простой UI, защита от детей. Выпечка = пар + конвекция.\n" +
"5. Инфраструктура. Газ исключает индукцию. Нет шахты = только рециркуляция.\n" +
"6. По каждой позиции: модель (линейка), цена, 2-3 преимущества под клиента, 1 предупреждение.\n\n" +
"Помогаешь менеджерам салонов согласовать с клиентом комплект техники.\n\n" +
"Принципы подбора:\n" +
"1. Уважай ценовой коридор. У каждой категории есть `price_ranges.{cat}.from..to` — попадай в него (±5% допустимо).\n" +
"2. Уважай предпочтения по брендам: сначала preferred (★), потом acceptable (✓), потом нейтральные альтернативы.\n" +
"3. Учитывай инфраструктуру: газ исключает индукцию (если клиент не готов менять подключение); нет вентиляции (`vent: no`) = только рециркуляция с угольным фильтром.\n" +
"4. Учитывай приоритеты выбора (`priorities`): «цена/качество» → балансные модели; «отзывы» → проверенные хиты; «дизайн и цвет» → подбирай эстетику; «технологичность» → топовые фичи.\n" +
"5. Если клиент явно отметил features в `per_cat.{cat}.features` — обязательно ставь модели с этими фичами.\n" +
"6. ВАЖНО: каждую тех. фичу в highlights ОБЯЗАТЕЛЬНО объясняй простым языком в скобках. Менеджер и клиент не обязаны знать что такое NoFrost / FlexZone / 4D HotAir.\n\n" +
"Примеры пояснений (используй такой стиль):\n" +
" «NoFrost (не нужно размораживать вручную)»\n" +
" «PowerBoost (форсаж — кипятит за минуту)»\n" +
" «FlexZone (объединяет зоны под большую сковороду)»\n" +
" «Hob2Hood (вытяжка автоматически работает синхронно с варочной)»\n" +
" «4D HotAir (конвекция с 4 сторон — равномерное запекание)»\n" +
" «Термощуп (готовит до точной температуры medium / well-done)»\n" +
" «AquaStop (защита от протечек — машина сама перекроет воду)»\n" +
" «SoftClose (дверца закрывается плавно, без хлопка)»\n" +
" «Инвертор (тише и экономия ~30% электричества)»\n\n" +
"Формат ответа — валидный JSON без markdown:\n" +
"{\n" +
' "summary": "...", \n' +
' "items": [{"category":"fridge","brand":"Bosch","model":"Serie 4 60см","price_rub":79990,' +
'"size_mm":{"w":600,"h":2030,"d":660},"fits_niche":true,"highlights":["NoFrost","инвертор"],' +
'"caveats":"Глубина 660мм","match_score":0.92}],\n' +
' "summary": "1-2 предложения общего вывода",\n' +
' "items": [{\n' +
' "category": "fridge",\n' +
' "brand": "Bosch",\n' +
' "model": "Serie 4 KGN39LB35R",\n' +
' "price_rub": 79990,\n' +
' "highlights": ["NoFrost (не нужно размораживать вручную)", "Инвертор (тише и экономия ~30%)", "Зона свежести (овощи дольше хрустящие)"],\n' +
' "caveats": "Глубина 660мм — на 60мм больше стандартной ниши",\n' +
' "match_score": 0.92,\n' +
' "tier_signal": "middle"\n' +
' }],\n' +
' "total_price_rub": 350000,\n' +
' "budget_status": "в_рамках|превышение|значительно_ниже",\n' +
' "client_temperature": "premium|middle|budget|mixed",\n' +
' "warnings": [],\n' +
' "next_steps": []\n' +
"}\n\n" +
"tier_signal — твоя оценка позиционирования модели. client_temperature — общий типаж клиента по сумме выбора (для аналитики).\n" +
"Не выдумывай несуществующие артикулы — указывай линейку (Bosch Serie 4 60см)."
);

View File

@ -42,6 +42,223 @@ const PODBOR_PRIORITIES = [
{ key: "service", label: "Сервис и гарантия" },
];
/* Параметры по категориям: Главное (всегда видно) + Подробнее (свёрнуто) */
const PODBOR_PARAMS = {
fridge: {
primary: [
{ key: "type", label: "Тип", options: [
{ key: "two_chamber", label: "Двухкамерный" },
{ key: "sbs", label: "Side-by-side" },
{ key: "french", label: "French Door" },
{ key: "column", label: "Колонна (встр.)" },
{ key: "combi", label: "Комбинированный" },
]},
{ key: "width", label: "Ширина, см", options: [
{ key: "54", label: "54" }, { key: "60", label: "60" },
{ key: "70", label: "70" }, { key: "75", label: "75" }, { key: "91", label: "91" },
]},
{ key: "volume", label: "Объём, л", options: [
{ key: "to300", label: "до 300" },
{ key: "300-450", label: "300450" },
{ key: "450-600", label: "450600" },
{ key: "600+", label: "600+" },
]},
{ key: "color", label: "Цвет", options: [
{ key: "white", label: "Белый" },
{ key: "inox", label: "Нерж. сталь" },
{ key: "black", label: "Чёрный" },
{ key: "anthracite", label: "Антрацит" },
{ key: "builtin", label: "Под фасад" },
]},
],
features: [
{ key: "nofrost", label: "NoFrost", hint: "не нужно размораживать вручную" },
{ key: "inverter", label: "Инвертор", hint: "тише и экономичнее на ~30%" },
{ key: "freshzone", label: "Зона свежести", hint: "овощи и зелень дольше хрустящие" },
{ key: "silent", label: "≤40 дБ", hint: "почти не слышно ночью" },
{ key: "smart", label: "Smart / Wi-Fi", hint: "управление с телефона" },
{ key: "ice", label: "Лёдогенератор", hint: "автоматически делает кубики" },
],
},
hob: {
primary: [
{ key: "heat", label: "Тип нагрева", options: [
{ key: "induction", label: "Индукция" },
{ key: "hi_light", label: "Hi-Light (стеклокерамика)" },
{ key: "gas", label: "Газ" },
{ key: "domino", label: "Domino (модульная)" },
]},
{ key: "width", label: "Ширина, см", options: [
{ key: "30", label: "30" }, { key: "45", label: "45" },
{ key: "60", label: "60" }, { key: "80", label: "80" }, { key: "90", label: "90" },
]},
{ key: "zones", label: "Число зон", options: [
{ key: "2", label: "2" }, { key: "3", label: "3" },
{ key: "4", label: "4" }, { key: "5", label: "5" },
]},
{ key: "color", label: "Цвет", options: [
{ key: "black", label: "Чёрный" }, { key: "white", label: "Белый" },
{ key: "frameless", label: "Без рамки" }, { key: "inox", label: "Нерж. сталь" },
]},
],
features: [
{ key: "boost", label: "PowerBoost", hint: "форсаж — кипятит за минуту" },
{ key: "flex", label: "FlexZone", hint: "объединяет зоны под большую сковороду" },
{ key: "hob2hood", label: "Hob2Hood", hint: "вытяжка автоматически следит за варочной" },
{ key: "child_lock", label: "Защита от детей", hint: "блокировка панели" },
],
},
oven: {
primary: [
{ key: "config", label: "Конфигурация", options: [
{ key: "compact_combi", label: "Компакт + СВЧ" },
{ key: "full_60", label: "Полный 60 см" },
{ key: "xl_90", label: "XL 90 см" },
{ key: "two_separate", label: "2 отдельных прибора" },
]},
{ key: "color", label: "Цвет", options: [
{ key: "black", label: "Чёрный" },
{ key: "inox", label: "Нерж. сталь" },
{ key: "white", label: "Белый" },
{ key: "blackglass", label: "Чёрное стекло" },
{ key: "anthracite", label: "Антрацит" },
]},
{ key: "cleaning", label: "Очистка", options: [
{ key: "hydro", label: "Гидролиз" },
{ key: "pyro", label: "Пиролиз" },
{ key: "eco", label: "Eco / каталитическая" },
{ key: "aqua", label: "Aqua" },
{ key: "std", label: "Стандарт" },
]},
],
features: [
{ key: "4d", label: "4D HotAir", hint: "конвекция с 4 сторон — равномерное запекание" },
{ key: "steam", label: "Пар", hint: "хлеб с румяной корочкой, мясо без пересушки" },
{ key: "probe", label: "Термощуп", hint: "готовит до точной температуры (medium / well-done)" },
{ key: "autopilot", label: "Автопилот", hint: "выбираешь блюдо — духовка сама ставит режим" },
{ key: "softclose", label: "SoftClose", hint: "дверца закрывается плавно" },
{ key: "smart", label: "Smart / Wi-Fi", hint: "следишь за приготовлением с телефона" },
],
},
dw: {
primary: [
{ key: "width", label: "Ширина, см", options: [
{ key: "45", label: "45" }, { key: "60", label: "60" },
]},
{ key: "mount", label: "Монтаж", options: [
{ key: "full_built_in", label: "Полная встройка (под фасад)" },
{ key: "partial", label: "Частичная встройка" },
{ key: "freestanding", label: "Отдельная" },
]},
{ key: "settings", label: "Комплектов", options: [
{ key: "8-9", label: "89 (для 23 человек)" },
{ key: "10-11", label: "1011 (семья 34)" },
{ key: "12-14", label: "1214 (большая семья)" },
]},
],
features: [
{ key: "aquastop", label: "AquaStop", hint: "защита от протечек — машина сама перекроет воду" },
{ key: "tray", label: "3-й лоток", hint: "отдельная полка для столовых приборов" },
{ key: "autoopen", label: "AutoOpen", hint: "приоткрывает дверь после мойки — сухая посуда" },
{ key: "silent", label: "≤44 дБ", hint: "можно мыть ночью, не слышно" },
{ key: "smart", label: "Smart / Wi-Fi", hint: "уведомление на телефон когда готово" },
],
},
hood: {
primary: [
{ key: "type", label: "Тип", options: [
{ key: "inclined", label: "Наклонная" },
{ key: "t_shape", label: "Т-образная" },
{ key: "dome", label: "Купольная" },
{ key: "built_in", label: "Встроенная" },
{ key: "telescopic", label: "Телескопическая" },
{ key: "island", label: "Островная" },
]},
{ key: "width", label: "Ширина, см", options: [
{ key: "50", label: "50" }, { key: "60", label: "60" },
{ key: "80", label: "80" }, { key: "90", label: "90" },
]},
{ key: "color", label: "Цвет", options: [
{ key: "inox", label: "Нерж. сталь" },
{ key: "black", label: "Чёрный" },
{ key: "white", label: "Белый" },
{ key: "black_glass", label: "Чёрное стекло" },
]},
{ key: "mode", label: "Режим работы", options: [
{ key: "exhaust", label: "Только отвод (вентиляция)" },
{ key: "recirc", label: "Только рециркуляция (фильтр)" },
{ key: "combi", label: "Оба режима" },
]},
],
features: [
{ key: "hi_perf", label: "Производительность 600+ м³/ч", hint: "сильно тянет — для большой кухни / wok" },
{ key: "perimeter", label: "Периметральная вытяжка", hint: "тянет с краёв — больше пара захватывает" },
{ key: "low_noise", label: "Тихая работа ≤50 дБ", hint: "не оглушает за столом" },
{ key: "smart", label: "Smart / Wi-Fi", hint: "автоматическая работа в паре с варочной" },
],
},
microwave: {
primary: [
{ key: "type", label: "Размещение", options: [
{ key: "builtin", label: "Встроенная" },
{ key: "freestanding", label: "Отдельная" },
]},
{ key: "volume", label: "Объём, л", options: [
{ key: "to20", label: "до 20" },
{ key: "20-25", label: "2025" },
{ key: "25+", label: "25+" },
]},
],
features: [
{ key: "grill", label: "Гриль", hint: "запекает корочку сверху" },
{ key: "convection", label: "Конвекция", hint: "работает как маленькая духовка" },
{ key: "inverter", label: "Инвертор", hint: "плавная мощность — не пересушивает" },
],
},
coffee: {
primary: [
{ key: "type", label: "Размещение", options: [
{ key: "builtin", label: "Встроенная" },
{ key: "freestanding", label: "Отдельная" },
]},
{ key: "tech", label: "Тип", options: [
{ key: "auto_grinder", label: "Автомат с кофемолкой" },
{ key: "capsule", label: "Капсульная" },
{ key: "manual", label: "Рожковая (бариста)" },
]},
],
features: [
{ key: "milk", label: "Капучинатор", hint: "автоматическое латте/капучино" },
{ key: "profiles", label: "Профили", hint: "у каждого свой размер/крепость" },
{ key: "smart", label: "Smart / Wi-Fi", hint: "управление с телефона" },
],
},
washer: {
primary: [
{ key: "type", label: "Размещение", options: [
{ key: "builtin", label: "Встроенная" },
{ key: "freestanding", label: "Отдельная" },
]},
{ key: "load", label: "Загрузка, кг", options: [
{ key: "to6", label: "до 6" },
{ key: "6-8", label: "68" },
{ key: "8-10", label: "810" },
{ key: "10+", label: "10+" },
]},
{ key: "depth", label: "Глубина", options: [
{ key: "slim", label: "Slim (до 45 см)" },
{ key: "standard", label: "Стандарт (60 см)" },
]},
],
features: [
{ key: "steam", label: "Пар", hint: "освежает без стирки, убивает аллергены" },
{ key: "dry", label: "Сушка", hint: "достал — и сразу в шкаф" },
{ key: "silent", label: "≤50 дБ", hint: "ночная стирка не разбудит" },
{ key: "smart", label: "Smart / Wi-Fi", hint: "запуск с телефона, уведомления" },
],
},
};
/* Бренды для каждой категории для чипов с тирами.
Сокращённый набор; полный список можно расширить из исходного HTML. */
const PODBOR_BRANDS = {

View File

@ -543,3 +543,250 @@
}
.empty { text-align: center; color: var(--muted); padding: var(--s7) 0; }
/* ============================================================
Detail menu list of categories with status
============================================================ */
.detail-list {
display: flex;
flex-direction: column;
gap: var(--s2);
}
.detail-card {
display: flex;
align-items: center;
gap: var(--s3);
background: var(--card);
border: 1px solid var(--line-strong);
border-radius: var(--r-card);
padding: var(--s3) var(--s4);
cursor: pointer;
transition: background 0.12s;
text-align: left;
}
.detail-card:active { background: var(--paper-2); }
.detail-card.done { border-color: var(--accent-2); }
.detail-icon {
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: var(--ink);
flex-shrink: 0;
}
.detail-icon svg { width: 22px; height: 22px; stroke-width: 1.4; }
.detail-card.done .detail-icon { color: var(--accent-2); }
.detail-text { flex: 1; min-width: 0; }
.detail-name {
font-family: var(--font-ui);
font-size: 14.5px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ink);
}
.detail-sum {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.06em;
color: var(--muted);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.detail-card.done .detail-sum { color: var(--ink-2); text-transform: none; letter-spacing: 0; font-family: var(--font-ui); font-size: 12.5px; }
.detail-status {
flex-shrink: 0;
width: 22px;
height: 22px;
display: grid;
place-items: center;
color: var(--muted-2);
}
.detail-status svg { width: 18px; height: 18px; }
.detail-card.done .detail-status {
width: 22px;
height: 22px;
background: var(--accent-2);
color: var(--paper);
border-radius: var(--r-pill);
}
.detail-card.done .detail-status svg { width: 14px; height: 14px; stroke-width: 2.5; }
/* ============================================================
Category detail screen
============================================================ */
.cat-detail-header {
display: flex;
align-items: center;
gap: var(--s3);
margin-bottom: var(--s4);
}
.cat-detail-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
color: var(--accent-2);
}
.cat-detail-icon svg { width: 26px; height: 26px; stroke-width: 1.4; }
.cat-detail-title {
font-family: var(--font-display);
font-style: italic;
font-size: 24px;
font-weight: 400;
margin: 0;
color: var(--ink);
flex: 1;
}
.param-group {
display: flex;
flex-direction: column;
gap: 6px;
padding: var(--s3) 0;
border-top: 1px solid var(--line);
}
.param-group:first-of-type {
border-top: none;
padding-top: 0;
}
.param-label {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
/* ============================================================
Accordion "Подробнее"
============================================================ */
.accordion-head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--s3) var(--s4);
background: var(--card);
border: 1px solid var(--line-strong);
border-radius: var(--r-card);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
}
.accordion-chev {
display: grid;
place-items: center;
color: var(--muted);
transition: transform 0.18s;
}
.accordion-chev svg { width: 16px; height: 16px; transform: rotate(90deg); transition: transform 0.18s; }
.accordion-chev.open svg { transform: rotate(-90deg); }
.accordion-body {
overflow: hidden;
max-height: 0;
transition: max-height 0.25s ease;
padding: 0;
}
.accordion-body.open {
max-height: 2000px;
padding-top: var(--s3);
}
.accordion-body > * + * { margin-top: var(--s3); }
/* ============================================================
Feature card (in accordion)
============================================================ */
.feature-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.feature {
display: grid;
grid-template-columns: 1fr 22px;
gap: var(--s2);
align-items: center;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--r-tag);
padding: 10px 12px;
cursor: pointer;
text-align: left;
transition: all 0.12s;
}
.feature:active { background: var(--paper-2); }
.feature.on {
border-color: var(--accent-2);
background: var(--paper-2);
}
.feature-name {
grid-column: 1 / 2;
font-family: var(--font-ui);
font-size: 13.5px;
font-weight: 600;
color: var(--ink);
line-height: 1.2;
}
.feature-hint {
grid-column: 1 / 2;
font-family: var(--font-ui);
font-size: 12px;
color: var(--muted);
line-height: 1.3;
margin-top: 2px;
}
.feature-tick {
grid-column: 2 / 3;
grid-row: 1 / 3;
width: 22px;
height: 22px;
display: grid;
place-items: center;
border: 1px solid var(--line-strong);
border-radius: var(--r-pill);
align-self: center;
}
.feature.on .feature-tick {
background: var(--accent-2);
color: var(--paper);
border-color: var(--accent-2);
}
.feature.on .feature-tick svg { width: 12px; height: 12px; stroke-width: 2.5; }

View File

@ -3,8 +3,11 @@
============================================================ */
const Podbor = (function () {
const STORAGE_KEY = "zov-podbor-v2";
const STEPS = ["intro", "categories", "pricing", "infra", "priorities", "brands", "summary"];
const STORAGE_KEY = "zov-podbor-v3";
const STEPS = ["intro", "categories", "detail", "pricing", "infra", "priorities", "brands", "summary"];
// Внутренний sub-state для шага «detail»: 'menu' | 'cat:<key>'
let detailView = "menu";
let state = loadState();
let root = null;
@ -24,6 +27,7 @@ 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 }, ... }
infra: { stove: "", vent: "" },
priorities: [], // ['balance','reviews',...]
@ -54,6 +58,7 @@ const Podbor = (function () {
function go(step) {
if (!STEPS.includes(step)) return;
currentStep = step;
detailView = "menu"; // на любой переход detail возвращается в меню
render();
window.scrollTo({ top: 0, behavior: "smooth" });
haptic && haptic("impact");
@ -70,6 +75,7 @@ const Podbor = (function () {
switch (currentStep) {
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 "infra": screen.appendChild(renderInfra()); break;
case "priorities": screen.appendChild(renderPriorities()); break;
@ -105,7 +111,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>
@ -165,7 +171,7 @@ const Podbor = (function () {
<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>
<button class="btn-primary" data-go="detail">Дальше</button>
</div>
</section>
`);
@ -183,6 +189,191 @@ const Podbor = (function () {
return node;
}
/* ===================== Step: detail — menu + per-category sub-screen ===================== */
function isCategoryFilled(catKey) {
const cat = state.per_cat[catKey];
if (!cat || !cat.params) return false;
const params = PODBOR_PARAMS[catKey]?.primary || [];
return params.every(p => cat.params[p.key]);
}
function renderDetail() {
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>
`);
}
if (detailView !== "menu" && detailView.startsWith("cat:")) {
const catKey = detailView.slice(4);
return renderCategoryDetail(catKey);
}
return renderDetailMenu();
}
function renderDetailMenu() {
const cards = state.categories.map(catKey => {
const cat = PODBOR_CATEGORIES.find(x => x.key === catKey);
const filled = isCategoryFilled(catKey);
const summary = filled ? buildPerCatSummary(catKey) : "Заполнить параметры";
return `
<button class="detail-card${filled ? " done" : ""}" data-cat="${catKey}">
<div class="detail-icon">${ICONS[cat.icon] || ""}</div>
<div class="detail-text">
<div class="detail-name">${cat.label}</div>
<div class="detail-sum">${summary}</div>
</div>
<div class="detail-status">${filled ? ICONS.check : ICONS.chevron}</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="detail-list">${cards}</div>
<div class="podbor-cta-row">
<button class="btn-secondary" data-go="categories">Назад</button>
<button class="btn-primary" data-go="pricing">Дальше</button>
</div>
</section>
`);
node.querySelectorAll(".detail-card").forEach(c => {
c.addEventListener("click", () => {
detailView = "cat:" + c.dataset.cat;
render();
});
});
bindNav(node);
return node;
}
function buildPerCatSummary(catKey) {
const cat = state.per_cat[catKey];
if (!cat || !cat.params) return "—";
const params = PODBOR_PARAMS[catKey]?.primary || [];
const labels = params
.map(p => {
const opt = p.options.find(o => o.key === cat.params[p.key]);
return opt ? opt.label : null;
})
.filter(Boolean);
return labels.join(" · ") || "—";
}
function renderCategoryDetail(catKey) {
const cat = PODBOR_CATEGORIES.find(x => x.key === catKey);
const config = PODBOR_PARAMS[catKey];
if (!config) {
return el(`<section class="podbor-step"><div class="empty">Параметры для «${cat?.label}» ещё не описаны.</div></section>`);
}
const catState = state.per_cat[catKey] || { params: {}, features: [], notes: "" };
const isExpanded = catState._expanded || false;
const primaryHtml = config.primary.map(p => {
const cur = catState.params?.[p.key] || "";
return `
<div class="param-group">
<div class="param-label">${p.label}</div>
<div class="opt-list">
${p.options.map(o => `
<button class="opt${cur === o.key ? " on" : ""}" data-param="${p.key}" data-val="${o.key}">${o.label}</button>
`).join("")}
</div>
</div>
`;
}).join("");
const featuresHtml = config.features.map(f => {
const on = (catState.features || []).includes(f.key);
return `
<button class="feature${on ? " on" : ""}" data-feat="${f.key}">
<div class="feature-name">${f.label}</div>
<div class="feature-hint">${f.hint}</div>
<div class="feature-tick">${on ? ICONS.check : ""}</div>
</button>
`;
}).join("");
const node = el(`
<section class="podbor-step podbor-cat-detail">
<header class="cat-detail-header">
<button class="podbor-back" aria-label="К меню">${ICONS.arrow_left}</button>
<div class="cat-detail-icon">${ICONS[cat.icon] || ""}</div>
<h2 class="cat-detail-title">${cat.label}</h2>
</header>
<div class="block">
<div class="block-head">Главное</div>
${primaryHtml}
</div>
<button class="accordion-head" data-toggle="exp">
<span>Подробнее</span>
<span class="accordion-chev${isExpanded ? " open" : ""}">${ICONS.chevron}</span>
</button>
<div class="accordion-body${isExpanded ? " open" : ""}">
<div class="hint">Технические фичи необязательно. Если не отметите, AI выберет сам и пояснит в подборе.</div>
<div class="feature-list">${featuresHtml}</div>
<label class="field">
<span class="field-label">Заметки по этой категории</span>
<textarea data-bind="cat_notes" rows="2" placeholder="Что-то особенное?">${catState.notes || ""}</textarea>
</label>
</div>
<div class="podbor-cta-row">
<button class="btn-secondary" id="catBack">К списку</button>
<button class="btn-primary" id="catSave">Сохранить</button>
</div>
</section>
`);
// Главное — radio
node.querySelectorAll("[data-param]").forEach(b => {
b.addEventListener("click", () => {
const cs = state.per_cat[catKey] || { params: {}, features: [], notes: "" };
cs.params = { ...(cs.params || {}), [b.dataset.param]: b.dataset.val };
update({ per_cat: { ...state.per_cat, [catKey]: cs } });
render();
});
});
// Features — toggle
node.querySelectorAll("[data-feat]").forEach(b => {
b.addEventListener("click", () => {
const cs = state.per_cat[catKey] || { params: {}, features: [], notes: "" };
const cur = cs.features || [];
cs.features = cur.includes(b.dataset.feat) ? cur.filter(x => x !== b.dataset.feat) : [...cur, b.dataset.feat];
update({ per_cat: { ...state.per_cat, [catKey]: cs } });
render();
});
});
// Accordion
node.querySelector("[data-toggle='exp']").addEventListener("click", () => {
const cs = state.per_cat[catKey] || { params: {}, features: [], notes: "" };
cs._expanded = !cs._expanded;
update({ per_cat: { ...state.per_cat, [catKey]: cs } });
render();
});
// Notes
const ta = node.querySelector("textarea[data-bind='cat_notes']");
if (ta) ta.addEventListener("input", e => {
const cs = state.per_cat[catKey] || { params: {}, features: [], notes: "" };
cs.notes = e.target.value;
update({ per_cat: { ...state.per_cat, [catKey]: cs } });
});
// Back / save → menu
node.querySelector(".podbor-back").addEventListener("click", () => { detailView = "menu"; render(); });
node.querySelector("#catBack").addEventListener("click", () => { detailView = "menu"; render(); });
node.querySelector("#catSave").addEventListener("click", () => { detailView = "menu"; render(); haptic && haptic("success"); });
return node;
}
/* ===================== Step: pricing (ценовой коридор по категориям) ===================== */
function renderPricing() {
@ -191,7 +382,7 @@ const Podbor = (function () {
<section class="podbor-step">
<div class="empty">Сначала выберите категории.</div>
<div class="podbor-cta-row">
<button class="btn-secondary" data-go="categories">Назад</button>
<button class="btn-secondary" data-go="detail">Назад</button>
</div>
</section>
`);
@ -234,7 +425,7 @@ const Podbor = (function () {
${totalLine}
</div>
<div class="podbor-cta-row">
<button class="btn-secondary" data-go="categories">Назад</button>
<button class="btn-secondary" data-go="detail">Назад</button>
<button class="btn-primary" data-go="infra">Дальше</button>
</div>
</section>

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