Нужен калькулятор ТО. Пользователь выбирает конфигурацию своего автомобиля по параметрам:
После того, как выбор сделан, появляется шкала километража с отметками периодичности ТО. Для автомобилей с бензиновыми двигателями шаг 15 000 км, для дизелей: 10 000 км. Так же есть "крышка" — максимально допустимый пробег, на котором оказываются услуги по обслуживанию автомобиля. Она разная на разных модификациях.
Далее пользователь, выбрав свой пробег, получает ближайший бОльший пробег, на котором делают ТО. Например, если я проехал 16 000 километров, то мое ТО — 30 000 км. Теперь вся информация для расчета у нас есть, показываем цену, список производимых работ и кнопку "записаться", при клике на которую появляется модальник с формой. Информация летит в инфоблок Битрикса и на почту. Автосервис получает лид и очень счастлив. Клиент и так счастлив, у него же мерседес :)
Вся эта красотища нужна нам в виде виджета, который в три строчки вставляется в любое место любого сайта, т.к. помимо раздела "ТО" на основном сайте сервиса хочется использовать его на лендинге, заточенном под конкретную услугу ТО.
В админке должна быть возможность настраивать лэйблы полей и сообщение об успешной отправки, а также основной цвет.
Срок: 2 рабдня.
Виджет значит. И чтоб цвет назначался. И лэйблы. И чтоб админилось все это. Ну ок, поехали:
Бэкенд: пишем битриксовый модуль, который нам спарсит 100500 моделей, модификаций, цен у официалов (парсер — это отдельная история). Храним всю информацию в инфоблоках. Их получается аж 8 штук:
Жить с официальными ценами не хочется, надо ими как-то управлять. Понятно, что вручную править 6500+ цен, это не только лишь долго, но и совсем. Решаем вопрос назначением коэффициентов для моделей и типов кузова.
Для общения с фронтом пишем небольшой php-класс, реализующий API.
Хорошо, с бэкендом понятно. Что с фронтом? Ежели виджет — надо подключать всего два файла: js-скрипт и файл стилей. Мы любим Vue.js, а он нас) Но хочется работать с полноценными компонентами в файлах .vue, использовать Sass, тестировать в изолированной среде и получать на выходе бандл из двух файлов. Выручает прекрасный vue-cli и замечательный webpack.
Вот она:
/widget/api/ — директория с API.
/widget/api/api.php — входная точка для запросов к API.
/widget/api/mmbApi.php — класс, реализующий работу с API.
/widget/mmb/ — фронтэнд. Собственно, сам виджет.
/widget/mmb/dist/ — сюда билдится продакшн-версия.
/widget/mmb/public/ — файлы для построения dev версии.
/widget/mmb/src/ — исходники.
/widget/mmb/src/components/Calc.vue — это Vue-компонент виджета.
/widget/mmb/src/App.vue — приложение Vue.
/widget/mmb/src/main.js — инициирующий файл.
/widget/mmb/vue.config.js — конфигурационный файл.
Вполне себе небольшая и понятная структура. Начнем рассмотрение с Back end:
Файл api.php, к которому мы обращаемся для взаимодействия из виджета, совсем небольшой.
Подключаем битриксовый пролог, класс для работы с апи и битриксовые модули каталога с инфоблоками:
<? require($_SERVER["DOCUMENT_ROOT"] . "/bitrix/modules/main/include/prolog_before.php");
include "mmbApi.php";
CModule::IncludeModule("catalog");
CModule::IncludeModule("iblock");
Затем чистим буфер, инициируем результирующий массив и получаем таск из запроса:
global $APPLICATION;
$APPLICATION->RestartBuffer();
$result = [];
$task = !empty($_REQUEST['task']) ? $_REQUEST['task'] : '';
Ну а дальше обработаем таски:
switch ($task) {
case 'get-data':
$api = new mmbApi();
$result = $api->getData();
break;
case 'get-sass-vars':
$api = new mmbApi();
$result = $api->getData();
$color = !empty($result['options']['field_main_color']) ? $result['options']['field_main_color'] : '#00ADEF';
$colorNoHex = str_replace('#', '', $color);
header("Cache-Control: no-store, no-cache, must-revalidate, max-age=0");
header("Cache-Control: post-check=0, pre-check=0", false);
header("Pragma: no-cache");
header('Content-Type: text/css');
print_r("
.mmb-main-color-static,
#mmbApp .mmbc .mmb-price-price .mmb-price-value,
#mmbApp .mmbc .mmb-select-css:focus,
.mmb-main-color-static div {
color: $color !important;
}
#mmbApp .mmbc .mmb-modal__body,
#mmbApp .mmbc .mmb-reply {
border-color: $color !important;
}
.mmb-main-color-back-static,
#mmbApp .mmbc .mmb-works__text .service__item:after,
#mmbApp .mmbc .mmb-spinner > div,
.vue-slider-process,
#mmbApp .mmbc .mmb-btn {
background-color: $color !important;
}
.mmb-main-color-hover:hover,
.mmb-main-color-hover:hover div {
color: $color !important;
}
.mmb-main-color-back-hover:hover {
background-color: $color !important;
}
#mmbApp .mmbc .mmb-select-css,
#mmbApp .mmbc .mmb-works .mmb-title span:after {
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23$colorNoHex%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E') !important;
}
");
exit;
break;
}
Их у нас два.
Переходим к нашему классу. А точнее, к методу getData:
public function getData()
{
$return = [];
// типы авто
$return += $this->cartypes();
// модели
$return += $this->models();
// года выпуска
$return += $this->years();
// типы кузова
$return += $this->bodytypes();
// модификации
$return += $this->modifications();
// текущая модификация
$return += $this->modification();
// отправка заявки
$return += $this->callback();
// настройки
$return += $this->options();
// запрос
$return += $this->request();
return $return;
}
Все просто и очевидно. Мы собираем данные по определенным сущностям и выдаем результирующий массив. Методы на получение данных из базы идентичны. Рассмотрим на примере modifications:
public function modifications()
{
$return = [];
if ($this->request['bodytype'] > 0) {
$modifications = $this->getElements([
'IBLOCK_ID' => self::IBLOCK_ID_MODIFICATION,
'PROPERTY_' . self::PROPERTY_CODE_CARTYPE_IN_MODEL => $this->request['cartype'],
'PROPERTY_' . self::PROPERTY_CODE_YEAR_IN_MODIFICATION => $this->request['year'],
'PROPERTY_' . self::PROPERTY_CODE_BODYTYPE_IN_MODIFICATION => $this->request['bodytype'],
], 100, [
'ID', 'IBLOCK_ID', 'PROPERTY_STEP', 'PROPERTY_MAX', 'NAME'
], ['SORT' => 'ASC'], false);
foreach ($modifications as $modification) {
$return[] = array(
'id' => $modification['fields']['ID'],
'name' => $modification['fields']['NAME'],
'step' => $modification['fields']['PROPERTY_STEP_VALUE'],
'max' => $modification['fields']['PROPERTY_MAX_VALUE'],
);
}
}
return ['modifications' => $return];
}
Модификации мы можем получить только если знаем тип автомобиля, год выпуска и тип кузова. Используем вспомогательный метод getElements(), который служит оберткой над CIBlockElement::GetList и выдает массив элементов.
Функция callback() занимается отправкой заявки в инфоблок и на емэйл:
public function callback()
{
$result = [];
if (!empty($this->request['callback'])) {
$PROP = [
'phone' => htmlspecialchars($this->request['callback_phone']),
'email' => htmlspecialchars($this->request['callback_email']),
];
$text = $this->request['callback_model'] . "\n";
$text .= $this->request['callback_year'] . "\n";
$text .= $this->request['callback_bodytype'] . "\n";
$text .= $this->request['callback_modification'] . "\n";
$text .= $this->request['callback_service'] . "\n";
$text .= $this->request['callback_price'] . "\n\n";
$text .= htmlspecialchars($this->request['callback_text']);
$itemToDb = [
"IBLOCK_SECTION_ID" => false,
"IBLOCK_ID" => self::IBLOCK_ID_CALLBACKS,
"NAME" => htmlspecialchars($this->request['callback_name']),
"PREVIEW_TEXT" => $text,
"ACTIVE" => "N",
"PROPERTY_VALUES" => $PROP
];
$el = new CIBlockElement;
$ID = $el->Add($itemToDb);
$el->SetPropertyValuesEx($ID, self::IBLOCK_ID_CALLBACKS, $PROP);
$elem = self::getElements(['IBLOCK_ID' => 37, 'ID' => $ID]);
$elem = array_values($elem)[0];
// добавление сообщения
$mName = $elem['fields']['NAME'];
$mText = $elem['fields']['~PREVIEW_TEXT'];
$mPhone = $elem['props']['phone']['VALUE'];
$mEmail = $elem['props']['email']['VALUE'];
// отправка письма с заказом админу
$emailFields = [
'mName' => $mName,
'mText' => $mText,
'mPhone' => $mPhone,
'mEmail' => $mEmail,
];
CEvent::Send('APP_CALC_MESSAGE', SITE_ID, $emailFields);
$result = ['reply' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_thx")];
$this->request['callback'] = 0;
}
return $result;
}
и делаем мы все это в случае, если заявка пришла из калькулятора. Признак: существование request['callback'].
В настройках модуля задаются лэйблы заголовков, подзаголовков и кнопок. Передаем их с помощью метода options():
public function options()
{
return [
'options' => [
'field_h_main' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_main"),
'field_h_auto' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_auto"),
'field_h_params' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_params"),
'field_h_works' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_works"),
'field_h_mileage' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_mileage"),
'field_h_mileage2' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_mileage2"),
'field_h_service' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_service"),
'field_h_price' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_price"),
'field_h_text' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_text"),
'field_h_name' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_name"),
'field_h_adds' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_adds"),
'field_h_thx' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_h_thx"),
'field_main_color' => \Bitrix\Main\Config\Option::get("morepages.mastermb", "field_main_color"),
]
];
}
Создаем проект с помощью vue-cli:
vue create mmb
Переходим в новый проект, выполняем
npm run build
смотрим директорию dist/js. Там сейчас несколько файлов вида:
app.95c13c5a.js
chunk-vendors.2ce01813.js
То есть мы только инициировали новое приложение, а файлов уже два. Начнем расти: файлов будет еще больше. И еще, каждый раз, когда мы делаем build, названия файлов разные. А что если код виджета уже установлен в нескольких местах на нескольких сайтах? Не будем же мы постоянно менять код вставки виджета во всех местах его присутствия. И так, мы хотим, чтобы .js файл был один и его название было статичным. То же касается и .css файла. Как это сделать?
Для этого в файле vue.config.js прописываем инструкции:
const webpack = require('webpack')
let assetsDir = "assets";
module.exports = {
assetsDir: assetsDir,
configureWebpack: {
output: {
filename: assetsDir + "/[name].js",
chunkFilename: assetsDir + "/[name].js"
},
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1
})
]
},
chainWebpack:
config => {
config.optimization.delete('splitChunks')
if (config.plugins.has("extract-css")) {
const extractCSSPlugin = config.plugin("extract-css");
extractCSSPlugin &&
extractCSSPlugin.tap(() => [
{
filename: assetsDir + "/[name].css",
chunkFilename: assetsDir + "/[name].css"
}
]);
}
}
}
Замечательно. Теперь при сборке в /dist/assets/ получаем два файла:
app.css
app.js
Переходим непосредственно к написанию компонента. Виджет небольшой, поэтому решено обойтись одним компонентом и не дробить на несколько мелких. Создаем файл Calc.vue, где и будет вся движуха.
В секции <template> начинаем построение компонента с вывода главного заголовка. Мы помним, что текст заголовка хранится в админке и выдается нашим API.
<div class="mmb-title mmb-title_big">
<span v-if="info && info.options">{{info.options.field_h_main}}</span>
</div>
Директивой v-if проверяем, пришла ли информация от API. Если их есть у нас, выводим. Аналогично мы поступаем со всеми текстовыми настройками, которые задаются в админке.
Далее начинаем выводить функциональные блоки. Первым идет выборка типа автомобиля:
<div v-if="info && info.cartypes" class="mmb-cartypes">
<div v-for="cartype in info.cartypes" :key="cartype.id" class="mmb-cartype mmb-main-color-back-hover"
@click="handleCartypeChecked(cartype.id)"
:class="{'active mmb-main-color-back-static': (info.request.cartype == cartype.id)}">
{{cartype.name}}
</div>
</div>
Проходим в цикле по типам и выводим кнопки. По событию click навешиваем хэндлер, по которому мы должны обновить информацию из API. Код хэндлера расположен в секции <script> опции methods нашего компонента:
handleCartypeChecked(cartypeId) {
this.info.request.cartype = cartypeId;
this.info.request.model = 0;
this.info.request.year = 0;
this.info.request.bodytype = 0;
this.info.request.modification = 0;
this.getData(this.info.request);
},
Здесь мы кладем в request выбранный тип автомобиля и скидываем в ноль выбор модели, года, типа кузова и модификации, если таковые уже были выбраны. Затем обращаемся к API с помощью функции getData(). Эта функция является единственным "шлюзом", через который происходит все общение с API. И вот как она выглядит:
getData(params, scrollToElement) {
params = params || {};
scrollToElement = scrollToElement || '';
let that = this;
document.getElementById('mmb-spinner').classList.add("show");
axios
.get('https://mastermb.ru/widget/api/api.php?task=get-data', {params: params})
.then(response => (this.info = response.data))
.then(function () {
if (scrollToElement != '') {
that.resetScrollPos(scrollToElement);
}
document.getElementById('mmb-spinner').classList.remove("show");
});
}
Метод принимает два параметра:
Ход выполнения следующий:
Далее идут блоки выбора модели, года выпуска, типа кузова и модификации:
<div v-if="info && info.models" class="mmb-models">
<div v-for="model in info.models" :key="model.id" class="mmb-model mmb-main-color-hover"
@click="handleModelChecked(model.id)"
:class="{'active mmb-main-color-static': (info.request.model == model.id)}">
<div class="mmb-model__preview">
<img :src="model.preview"/>
</div>
<div class="mmb-model__title">
{{model.name}}
</div>
</div>
</div>
<div v-if="info && info.request.model > 0" class="mmb-params" id="mmb-params">
<div class="mmb-params__params">
<div class="mmb-title mmb-title_w100">
<span v-if="info && info.options">{{info.options.field_h_params}}</span>
</div>
<div v-if="info && info.years && info.years.length" class="mmb-years">
<select v-model="info.request.year" @change="handleYearChecked($event)" class="mmb-select-css">
<option value="0">--= год ==-</option>
<option v-for="year in info.years" :key="year.id" :value="year.id">
{{year.name}}
</option>
</select>
</div>
<div v-if="info && info.bodytypes && info.bodytypes.length" class="mmb-bodytypes">
<select v-model="info.request.bodytype" @change="handleBodytypeChecked($event)"
class="mmb-select-css">
<option value="0">--= тип кузова ==-</option>
<option v-for="bodytype in info.bodytypes" :key="bodytype.id" :value="bodytype.id">
{{bodytype.name}}
</option>
</select>
</div>
<div v-if="info && info.modifications && info.modifications.length" class="mmb-modifications">
<select v-model="info.request.modification" @change="handleModificationChecked($event)"
class="mmb-select-css">
<option value="0">--= модификация ==-</option>
<option v-for="modification in info.modifications" :key="modification.id"
:value="modification.id">
{{modification.name}}
</option>
</select>
</div>
</div>
<div class="mmb-params__preview">
<div v-if="info && info.request.modification > 0">
<div class="mmb-model__preview">
<img :src="info.modification.preview"/>
</div>
</div>
<div v-if="info && info.request.modification == 0">
<div v-for="model in info.models" :key="model.id">
<div class="mmb-model__preview" v-if="info.request.model == model.id">
<img :src="model.preview"/>
</div>
</div>
</div>
</div>
</div>
Модели у нас в виде тизера "картинка + название". Год, кузов и модификация — select'ы. Как только последний из селектов выбран, можем переходить к показу цен и выборке пробега.
<div v-if="info && info.modification.length != 0">
<div class="mmb-works" :class="{'mmb-togglable-closed': worksClosed, 'mmb-togglable-opened': !worksClosed}">
<div class="mmb-title" @click="handleWorksToggle()">
<span v-if="info && info.options">{{info.options.field_h_works}}</span>
</div>
<div class="mmb-works__text">
<div class="mmb-works__text__inner" v-html="serviceWorks"></div>
</div>
</div>
<div class="mmb-title">
<span v-if="info && info.options">{{info.options.field_h_mileage}}</span>
</div>
<div class="mmb-mileage">
<vue-slider
v-model="sliderValue"
:marks="sliderData"
:contained="true"
:min="0"
:max="parseInt(info.modification.max)"
:interval="1000"
:railStyle="{height: '10px', 'border-radius': 0}"
></vue-slider>
</div>
<div class="mmb-price-box">
<div class="mmb-price-mileage">
<div class="mmb-price-header">
<span v-if="info && info.options">{{info.options.field_h_mileage2}}</span>
</div>
<div class="mmb-price-value">{{ formatNumber(sliderValue) }} км</div>
</div>
<div class="mmb-price-service">
<div class="mmb-price-header">
<span v-if="info && info.options">{{info.options.field_h_service}}</span>
</div>
<div class="mmb-price-value">{{ formatNumber(serviceVal) }} км</div>
</div>
<div class="mmb-price-price">
<div class="mmb-price-header">
<span v-if="info && info.options">{{info.options.field_h_price}}</span>
</div>
<div class="mmb-price-value">{{ servicePrice }} *</div>
</div>
<div>
<button class="mmb-btn" @click="modalShow()">Записаться</button>
</div>
</div>
<div class="mmb-annotation mmb-text-gray">
<span v-if="info && info.options">{{info.options.field_h_text}}</span>
</div>
...
</div>
Пробег удобно выбирать с помощью слайдера. Для этого используем <vue-slider>. Текущая цена является вычисляемым полем. Задаем ее в блоке computed:
servicePrice: function () {
var price = 0;
if (this.info && this.info.modification.length != 0) {
let currentService = this.serviceVal;
this.info.prices.forEach(function (element) {
if (parseInt(element.mileage) == currentService) {
price = element.price;
}
});
}
price *= parseFloat(this.currentModel.k);
price *= parseFloat(this.currentBodytype.k);
if (price == 0) {
price = 'По запросу';
} else {
price = this.formatNumber(price) + ' ₽';
}
return price;
}
Оставляем возможность вывода цены "по запросу", указывая в админке ноль.
Ну и обычная лидовая форма в модальнике.
Для вставки виджета будем использовать код:
<div id="mmbApp"></div>
<script>
(function (w, d, u, u2) {
var s = d.createElement('script');
s.async = true;
s.src = u + '?' + (Date.now() / 60000 | 0);
var h = d.getElementsByTagName('script')[0];
h.parentNode.insertBefore(s, h);
var s2 = d.createElement('link');
s2.href = u2 + '?' + (Date.now() / 60000 | 0);
s2.rel = 'stylesheet';
s2.type = 'text/css';
var h2 = d.getElementsByTagName('link')[0];
h2.parentNode.insertBefore(s2, h2);
})(window, document, 'https://mastermb.ru/widget/mmb/dist/assets/app.js', 'https://mastermb.ru/widget/mmb/dist/assets/app.css');
</script>
Почему бы просто не разместить обычные теги <script> и <link>? Все дело в кешировании. В случае со статичными урлами .js и .css файлов, при внесении изменений в работу виджета, мы не сможем гарантировать мгновенное применение этих изменений. Файлы будут закешированы на клиенте. Получится, что "старый" код виджета будет работать с "новым" API кодом. И хорошо если в API изменений нет. В противном случае случай будет противным)
А с помощью данного кода мы создаем все те же теги <script> и <link>, но уже средствами JavaScript. Зачем? Чтобы добавить к урлам наших файлов переменную величину в виде даты. Таким образом виджет всегда будет оставаться свеженьким.
Сам проект, где можно увидеть виджет: mastermb.ru
На этом все! Ставьте лайки, делайте репосты, рекомендуйте нас друзьям!
Остались вопросы? Пишите!