Блог /

Виджет калькулятора ТО для автомастерской Mercedes на Vue.js с бэкендом на Bitrix

Автор: Марк Мишко
23.07.2019
6135

Постановка задачи

Нужен калькулятор ТО. Пользователь выбирает конфигурацию своего автомобиля по параметрам:

  • Тип автомобиля (легковой, внедорожник, минивэн) в виде кнопок;
  • Модель в виде списка превью+название;
  • Год выпуска выпадающий список;
  • Тип кузова выпадающий список; 
  • Модификация выпадающий список.

После того, как выбор сделан, появляется шкала километража с отметками периодичности ТО. Для автомобилей с бензиновыми двигателями шаг 15 000 км, для дизелей: 10 000 км. Так же есть "крышка" — максимально допустимый пробег, на котором оказываются услуги по обслуживанию автомобиля. Она разная на разных модификациях.

Далее пользователь, выбрав свой пробег, получает ближайший бОльший пробег, на котором делают ТО. Например, если я проехал 16 000 километров, то мое ТО — 30 000 км. Теперь вся информация для расчета у нас есть, показываем цену, список производимых работ и кнопку "записаться", при клике на которую появляется модальник с формой. Информация летит в инфоблок Битрикса и на почту. Автосервис получает лид и очень счастлив. Клиент и так счастлив, у него же мерседес :)

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

В админке должна быть возможность настраивать лэйблы полей и сообщение об успешной отправки, а также основной цвет.

Срок: 2 рабдня.

Как делать то? Реализация

Виджет значит. И чтоб цвет назначался. И лэйблы. И чтоб админилось все это. Ну ок, поехали:

Бэкенд: пишем битриксовый модуль, который нам спарсит 100500 моделей, модификаций, цен у официалов (парсер — это отдельная история). Храним всю информацию в инфоблоках. Их получается аж 8 штук:

  1. Тип авто,
  2. Модель,
  3. Год выпуска,
  4. Тип кузова,
  5. Модификация,
  6. Цены,
  7. Работы,
  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:

Back end. API калькулятора.

Файл 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;
}

Их у нас два.

  • get-data выдает данные для построения интерфейса виджета;
  • get-sass-vars выдает захардкоденый кусок стилей, которые отвечают за оформление интерфейса, по сути, реализуя цветовую схему. Она, как мы помним, задается в настройках битриксового модуля путем указания главного цвета.

Переходим к нашему классу. А точнее, к методу 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"),
			]
		];
	}

Front End. 

Создаем проект с помощью 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");
		});
}

Метод принимает два параметра:

  1. params — параметра запроса; 
  2. scrollToElement — селектор элемента, к которому нужно промотать окно браузера после получения данных из API.

Ход выполнения следующий:

  1. "Перекладываем" this в that, чтобы можно было использовать объект компонента в коллбэках;
  2. Показываем индикатор загрузки;
  3. Выполняем запрос к API;
  4. Обновляем информацию;
  5. Если нужно скроллить к элементу, делаем это с помощью специального метода that.resetScrollPos;
  6. Прячем индикатор загрузки.

Далее идут блоки выбора модели, года выпуска, типа кузова и модификации:

	<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

На этом все! Ставьте лайки, делайте репосты, рекомендуйте нас друзьям!

Остались вопросы? Пишите!

Заказать разработку