javascript

Калькулятор сметы и фильтр каталога. Что теряется между макетом и кодом

  • среда, 28 октября 2026 г. в 00:00:11
https://habr.com/ru/articles/1099848/

Я проектировал каталог и калькулятор сметы для сайта компании, которая строит загородные дома. Моя часть была в проектировании и дизайне, вёрстку и бэкенд делала другая команда. Сайт запущен и работает. Недавно я снял с него разметку, CSS и клиентский скрипт и положил рядом с макетами, чтобы проверить, как решения дошли до кода.

Расскажу, как я раскладывал смету на данные, как она устроена в продакшене, какие граничные случаи я оставил без правила и какую спецификацию я передал бы сейчас. Почти все ошибки ниже начинаются в макете, а не у разработчика.

Цены на сайте и стек без фреймворка

Дом из бруса стоит несколько миллионов, выбирают его месяцами. На старом сайте цен не было, сумму называли по телефону. Нужно было, чтобы человек сам сузил каталог до своего бюджета, открыл проект и собрал комплектацию с итоговой суммой.

Ограничения задал стек. Сайт на WordPress со своей темой, данные проектов лежат в записях, фильтр ходит на admin-ajax.php. Никакого фронтенд-фреймворка нет, только DOM, noUiSlider для ползунков и Swiper для галерей. Значит, состояние сметы будет жить в разметке, и макет должен заранее сказать, какие атрибуты у неё будут.

Модель сметы

Перед тем как рисовать смету, я разложил её на типы строк. Получилось три типа и одно правило итога.

Тип строки

Поведение

Что меняет в итоге

Вариант материала (брус 140, 170, 190 мм, клеёный 160 мм)

переключатель, выбран ровно один

задаёт базу

Входит в цену (фундамент, стеновой комплект, перекрытия, кровля)

галочка стоит и не снимается, есть пояснение

ничего, но может сделать итог приблизительным

Добавить к заказу (водопровод, септик, забор, баня)

обычный чекбокс с суммой «+ N руб.»

прибавляет цену

В макете справа от списка стоит колонка итога. Она повторяет выбор коротким списком, у каждой платной опции значок корзины, внизу сумма и кнопка. Корзина и чекбокс слева управляют одним и тем же значением, у состояния две точки входа.

Верх карточки в макете. Цена базового материала крупно, под ней цены других вариантов, у клеёного бруса «от».
Верх карточки в макете. Цена базового материала крупно, под ней цены других вариантов, у клеёного бруса «от».
Смета в макете. Слева включённое и опции, справа колонка итога с корзинами.
Смета в макете. Слева включённое и опции, справа колонка итога с корзинами.

Как смета устроена в продакшене

Разработчик выбрал короткий путь. Каждая строка сметы это input type="checkbox" с ценой в data-value. Включённые в цену строки отдаются сервером с checked disabled и data-value="0", платные опции без disabled и с суммой. База лежит в атрибуте блока итога.

<input class="ui-checkbox__input" type="checkbox" checked disabled
       name="Фундамент" data-name="Фундамент" data-value="0">
<input class="ui-checkbox__input" type="checkbox"
       name="Водосточная система" data-name="Водосточная система" data-value="…">

<div class="parameters__value parameters__value--initial" data-value="…">… руб.</div>
<div class="parameters__value parameters__value--total"><strong>…</strong></div>

Пересчёт делает одна функция. Она собирает все отмеченные чекбоксы, прибавляет положительные data-value к базе, перерисовывает список справа и пишет итог через toLocaleString("ru-RU"). Включённые строки попадают в правую колонку без суммы и без корзины, это решается флагом disabled. Корзина находит чекбокс по атрибуту name и снимает галочку, после чего пересчёт запускается заново. Подписка на change у всех чекбоксов вешается при загрузке.

Схема рабочая и для такого сайта адекватная. Проблемы начинаются на краях, и их я должен был описать в макете.

Включённое в цену бывает неточным. В макете фундамент стоит во включённом с пояснением, что тип и стоимость зависят от грунта, а итог при этом круглый, без «от». Модель не знала, что у строки может не быть точной цены, и разработчику неоткуда было взять это правило.

Поиск по name хрупкий. Корзина собирает селектор input[name="${название}"] из data-name, то есть из подписи строки. Пока в подписях нет кавычек, это работает. Строка вида Брус "Премиум" сломает и атрибут, и селектор. Идентификатор строки должен быть отдельным полем, а не её подписью.

Одно состояние подписано двумя словами. В макете перекрытия слева подписаны «входит в стоимость», а справа «бесплатно». В смете это опасно, человек ищет подвох там, где его нет.

Какой контракт я передал бы сейчас

Сейчас я отдавал бы вместе с макетом модель и чистую функцию расчёта. Модель с явными идентификаторами и флагом неточной цены.

{
  "variants": [
    {"id": "brus140",  "title": "Брус 140 мм",         "base": 3600000},
    {"id": "glued160", "title": "Клеёный брус 160 мм", "base": 4800000, "approx": true}
  ],
  "included": [
    {"id": "foundation", "title": "Фундамент", "approx": true,
     "note": "тип и стоимость зависят от грунта"},
    {"id": "walls", "title": "Стеновой комплект"}
  ],
  "options": [
    {"id": "septic", "title": "Септик", "price": 780000},
    {"id": "fence",  "title": "Забор", "price": 150000}
  ]
}

И функцию, которую можно вызвать и на сервере при рендере, и в браузере при клике.

function computeEstimate(model, variantId, selectedIds) {
  const variant = model.variants.find(v => v.id === variantId) ?? model.variants[0];
  const options = model.options.filter(o => selectedIds.includes(o.id));

  const total = variant.base + options.reduce((sum, o) => sum + o.price, 0);
  const approx = Boolean(variant.approx) || model.included.some(i => i.approx);

  return {
    total,
    approx,                       // итог подписывается «от» и получает сноску
    lines: [
      { id: variant.id, title: variant.title, price: variant.base, removable: false },
      ...model.included.map(i => ({ id: i.id, title: i.title, price: null, removable: false })),
      ...options.map(o => ({ id: o.id, title: o.title, price: o.price, removable: true })),
    ],
  };
}

Флаг approx закрывает историю с фундаментом. Если хоть одна строка без точной цены, итог тоже «от», и это решает функция, а не человек, который заполняет карточку. price: null у включённого даёт одну подпись на всё, «входит в стоимость», и слово «бесплатно» больше негде взять. Корзина работает по id.

Числа в самом макете я вводил руками, и в макете сметы они не сходятся. Наверху карточки 3,6 миллиона, в колонке итога базой стоит 2,2 миллиона. Строки в колонке складываются в 3 220 000, а итог подписан 3 290 000. В одной опции опечатка «+ 150 0000». Каждое число по отдельности выглядело правдоподобно. Если бы демонстрационные суммы в макет шли из computeEstimate на тестовой модели, этого бы не случилось, а разработчик получил бы проверочные пары «вход, ожидаемый итог».

Фильтр каталога

Фильтр собран из шести параметров, которые покупатель называет сам. Цена и площадь это диапазоны, для них два поля «от» и «до» и ползунок под ними. Этажи, спальни, санузлы и технология это группы чекбоксов. У этажей третье значение «Мансарда», у спален и санузлов последнее значение «3+».

Фильтр в макете. Поля и ползунки для цены и площади, группы чекбоксов, переключатель «списком / на карте».
Фильтр в макете. Поля и ползунки для цены и площади, группы чекбоксов, переключатель «списком / на карте».

В продакшене скрипт читает поля и отмеченные чекбоксы, кладёт их в FormData с action=filter_projects, отправляет POST и вставляет полученный HTML в контейнер выдачи. Те же параметры пишутся в адресную строку через history.pushState, поэтому отфильтрованную выдачу можно отправить ссылкой. Группы чекбоксов уходят JSON-массивами, floors=["1","mansarda"]. Запрос откладывается debounce на 250 мс, чтобы перетаскивание ползунка не превращалось в десятки запросов. Для режима карты тот же сбор параметров отправляет флаг for_map, сервер отвечает JSON с координатами, и они уходят в ObjectManager Яндекс Карт.

Границы диапазона, которые я нарисовал

В макете на шкале цены стоят крайние значения каталога на момент проектирования, около двух и около тринадцати миллионов, на шкале площади тоже два числа. В коде ползунки инициализируются ровно этими числами, ниже они округлены.

priceSlider = twoRangeSliderInit(priceEl, { min: 2000000, max: 13000000, round: 100, /* … */ });
areaSlider  = twoRangeSliderInit(areaEl,  { min: 100,     max: 400,      round: 5,   /* … */ });

Разработчик взял их из макета, другого источника у него не было. Константа на краю шкалы работает, пока каталог в неё помещается. Цены же со временем растут. Когда появится проект дороже верхней границы, первая загрузка каталога его покажет, а любой запрос фильтра уйдёт с верхней границей из макета. Стоит человеку тронуть любой чекбокс, и самые дорогие дома молча исчезнут из выдачи, хотя ползунок цены стоит в положении «до упора».

Исправление в две части. Границы считаются из данных, MIN и MAX по опубликованным проектам, и отдаются в шаблон. А крайнее положение ползунка означает «без ограничения», а не конкретное число.

function rangeParams(name, [from, to], bounds) {
  const params = {};
  if (from > bounds.min) params[`${name}_from`] = from;
  if (to < bounds.max)   params[`${name}_to`] = to;   // ручка у края, фильтра нет
  return params;
}

Тогда добавленный завтра дом за 15 миллионов не потеряется, даже если шаблон забудут пересобрать. В макете это одна строка подписи у ползунка, «границы из каталога, крайнее положение снимает ограничение». Я её не написал.

Два входа в один отбор

Над фильтром в макете стоят чипы категорий. Все проекты, брус, каркас, камень, шале, бани. В группе «Технология» снова брус, каркас и камень. Чип «Дома из бруса» и галочка «Брус» отбирают одно и то же, а в макете это два независимых элемента.

В продакшене конфликт решили по-своему. Категории стали отдельными адресами, путём страницы или параметром ?cat=, скрипт берёт категорию из пути или из параметра. А группа «Технология» на странице каталога выводится пустой, без единого чекбокса. Одно из двух состояний разработчик выключил. Для пользователя это приемлемо, но решение принято в коде, без макета. Правило стоило записать мне. Клик по чипу ставит галочку в группе и наоборот, состояние одно.

Ещё одно состояние описано плохо. Пустая выдача в коде это одна строка «Проекты не найдены», и всё. В макете у меня была кнопка «Индивидуальный проект» в нижней строке фильтра, и для пустой выдачи я решил, что этого хватит. Отдельный экран с подсказкой, какой параметр ослабить, был бы полезнее.

Сетка выдачи

В макете выдача модульная. Проект, который стоит выделить, занимает два модуля, остальные один, и длинный список не превращается в однородную ленту.

Выдача в макете. Крупные карточки занимают два модуля, у проектов со скидкой старая цена зачёркнута.
Выдача в макете. Крупные карточки занимают два модуля, у проектов со скидкой старая цена зачёркнута.

В продакшене выдача это l-grid l-grid-cols-4, четыре равные колонки, ниже 1024 px две. Крупных карточек нет. Причина понятна. Чтобы сетка была модульной, у проекта должен быть признак «крупный» и правило, где такие карточки стоят при любой сортировке и любом фильтре. В макете я нарисовал одну красивую раскладку на 16 карточек и не описал, что с ней будет, когда фильтр оставит пять. Без правила разработчик выбрал единственный вариант, который не ломается, и был прав.

Токены

Цвета и типографика в CSS лежат в переменных и совпадают с макетом. Значения сняты из style.css.

Переменная

Значение

Где работает

--color-accent

#6C885E

кнопки, ползунки, выделение текста

--color-second

#8E616C

старая цена, плашка скидки, ошибки форм

--color-base

#202020

текст

--color-grey

#E3E1D8

теги, фон секций

--color-background

#F9F9F9

фон страницы, текст на кнопке

--color-border

#B3B3B3

рамки, плейсхолдеры, неактивное

Шрифты Unbounded для заголовков и PP Neue Montreal для текста. Корневой размер 62.5%, поэтому 1rem равен 10 px. Заголовки масштабируются переопределением переменных в трёх медиазапросах.

Ширина

--title-size-lg

--title-size

--title-size-sm

--text-size

от 1367

56

40

32

20

до 1366

48

40

32

20

до 1023

40

32

24

20

до 767

32

24

20

16

В макете ягодный #8E616C значил только деньги, старую цену и скидку. В коде тот же цвет стал ещё и --color-error. На странице проекта со скидкой и ошибкой в форме заявки один цвет говорит две разные вещи. Токены нужно было назвать по роли ещё в макете, price-old и error, пусть даже с одинаковым значением.

И ошибка, которая целиком моя. Основная кнопка это текст #F9F9F9 на #6C885E, 16 px, вес 500, капс. Контраст 3,75:1. Для текста такого размера WCAG AA требует 4,5:1, порог 3:1 действует для 24 px или для 18,66 px жирным. Кнопка «Оставить заявку» не проходит AA. Зелёный я подбирал под фотографии леса, контраст не посчитал. Тёмный текст #202020 не спасает, у него 4,13:1. Значит, менять пришлось бы сам зелёный, в сторону темнее.

Что я делаю иначе после этого проекта

Вместе с макетом расчёта отдаю модель данных и функцию итога, а демонстрационные числа в макете считаю этой функцией. Ручные суммы расходятся всегда.

Каждое число на шкале подписываю происхождением. Если граница взята из данных, так и пишу, иначе её зашьют константой.

У каждого элемента, которому нужна правка по данным (крупная карточка, «от» в итоге, неточная строка), описываю правило, а не рисую один удачный пример. Пример разработчик повторит. Правило он реализует.

Цвета называю по роли и считаю контраст до того, как показываю палитру заказчику.