Блог /

Корзина и оформление заказа на Bitrix d7 api + Vue.js

Обновили один из наших любимых проектов — доставка еды Allepizza.ru. Это все тот же 1C Bitrix, но на api d7. И, каким бы ни был продуманным, удобным и функциональным новый стандартный компонент оформления заказа sale.order.ajax, нам его "не хватило". Трехшаговое оформление заказа, предоставление пользователю возможности выбора между накоплением баллов и применением скидок, расчет начисляемых баллов "на лету", оплата баллами, применение нескольких купонов, расчет стоимости доставки и минимальной суммы заказа в зависимости от выбранного района — и все это в эксклюзивном адаптивном дизайне — должно работать четко, быстро и безотказно.

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

Итак, поехали. Нам нужен компонент оформления заказа из трех шагов:

Шаг 1: Корзина.

Выводим стандартный список товаров к заказу с фотографией, названием, описанием, доп. свойствами, количеством, ценой за единицу товара, сумму без скидки и сумму со скидкой.

В Allepizza в корзину добавляются дополнительные товары по умолчанию при заказе блюд японской кухни: имбирь, васаби, соевый соус. Причем рассчитываются порции относительно объема заказываемых блюд.

При добавлении в корзину половинок пицц нужно проверить, не "болтается" ли одиночная половинка определенного размера (30 или 40 см). Если есть такая — заказывать не даем. 

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

Также на первом шаге располагаются свойства заказа, которые могут повлиять на его стоимость:

  • выбор типа доставки: доставка на дом или самовывоз. на самовывоз -10%;
  • выбор региона доставки;
  • выбор времени, к которому хотелось бы получить заказ (как можно скорее либо определенное время);
  • выбор способа оплаты (при оплате онлайн скидка 5%);
  • выбор доступной скидки из списка: Скидка в офис (-5%), скидка на День Рождения (-10%), Счастливые часы (-20% с 14 до 16);
  • поле ввода купона(ов).

Шаг 2: Баллы или скидка.

На этом шаге даем пользователю выбрать: начислить баллы либо воспользоваться скидками. Если пользователь выбирает баллы, и у него на счету уже они есть, предоставляем возможность их слить) Воспользоваться баллами может только авторизованный пользователь, о чем мы и сообщаем в случае, если последний не авторизован. Баллы могут начисляться только на те товары, которые администратор выберет. При чем на разные товары может быть выставлен свой процент, который определяет количество начисляемых баллов. Баллы отменяют скидки. 

Шаг 3: Контактная информация.

Самый простой шаг. Здесь мы собираем все те свойства заказа, которые не могут повлиять на его стоимость:

  • Имя;
  • Телефон;
  • Улица;
  • Дом;
  • Подъезд;
  • Этаж;
  • Квартира/офис;
  • Количество персон;
  • Нужна сдача с;
  • Комментарий.

Ну и как обычно, надо сделать быстро и чтоб работало) Вообще существует тонкая грань между "затянуть сроки и сделать вот прям как следует" и "по-быстрому выдать рабочий вариант с кучей костылей и багов". Впрочем, баги будут и в первом случае, но их агрессивность и количество значительно уменьшатся. Сверху накладывается "мастерство" разработчика: можно захотеть "сделать как следует", затянуть в итоге сроки и не сделать. Еще сверху — сбалансированная коммерческая выгода как разработчиков продукта так и заказчика.

Это все лирика, а вывод прост: всегда надо стараться находить наиболее эффективное (быстрое и простое) решение. Не кидаться сразу в разработку, а изучить варианты, смоделировать ситуацию и выработать стратегию воплощения именно эффективного решения.

Реализация

Бэкенд — свой собственный компонент на d7. Фронтенд — Vue.js. Почему Vue? Как раз по тому же, что он как никто другой позволяет реализовать задачу эффективно, избавляя от тонны неудобств ковыряния DOM'а своими руками, в котором можно увязнуть надолго и усложнить код до безобразия. Чистый и понятный самодокументируемый код. Навсегда. 

Шаг 1: Корзина.

На первом шаге самый смак. Из постановки задачи возникает ряд вопросов. Приведем список вопросов с их концептуальными решениями:

  1. Как структурно оформить дополнительные товары?
    Здесь все просто: дополнительный товар, это товар с характеристикой dop. Будем фильтровать таких в конец списка;
  2. Каков алгоритм проверки половинок пицц?
    Соберем массив с двумя ключами: [30 => __количество-половинок-диаметром-30__, 40 => __количество-половинок-диаметром-40__]. Если остаток от деления на 2 в каком-либо ключе равен 1, считаем что условие не выполнено и одна половинка "холостая", надо найти пару;
  3. Как сделать скидку при выборе самовывоза?
    Ну во-первых создать ее в правилах работы с корзиной) А дальше два пути: создать для скидки купон и применить его, если пользователь выбрал самовывоз. Или же закидывать в корзину товар-маркер "самовывоз" каждый раз, когда пользователь выбирает самовывоз, и удалять товар-маркер, когда пользователь самовывоз "развыбирает". Останавливаемся на первом варианте;
  4. Как рассчитать стоимость доставки в зависимости от выбранного региона?
    Создаем специально для этой информации инфоблок. И по событию onSaleDeliveryServiceCalculate "внедряемся" в процесс расчета стоимости доставки в файле init.php, где и реализуем нашу логику;
  5. При выборе желаемого времени доставки отменяется скидка "счастливые часы", если таковая действует. Как это сделать?
    Закидываем в корзину товар-маркер "Заказ ко времени" каждый раз, когда пользователь выбирает время. Удаляем товар-маркер, когда пользователь "развыбирает" время. Создаем условие в правиле работы с корзиной "Счастливые часы", в котором говорим "Если отсутствует товар "Заказ ко времени"";
  6. Как в Битриксе настроить скидку "Счастливые часы" в определенные часы?
    Если вкратце, то никак. Нет такого условия в правилах работы корзины (актуально на момент написания статьи). Наверное, оно таки появится в следующих релизах. А пока его нету, выручит нас любимый cron, по которому и будет происходить активация и деактивация данного правила работы с корзиной. Для этих целей решено было создать инфоблок с тасками, в котором можно настраивать дни недели, время включения, время выключения, принудительные даты включения/отключения. И поставить в cron ежеминутное выполнение подходящих в данный момент тасков. Конечно, можно было бы решить вопрос с помощью костылей в коде самого нашего компонента, но мы выбрали меньшую из двух зол;
  7. Как реализовать выбор определенной скидки?
    Как и в случае с самовывозом: создать "системные скидки", которые доступны к выбору (их набор ограничен и статичен), создать в этих скидках соответствующие купоны и применять их с помощью api.

Back end.

Входная функция компонента:

function executeComponent()
	{
		Sale\Compatible\DiscountCompatibility::stopUsageCompatible();
		
		$action = $_REQUEST['action'];
		$mode = !empty($_REQUEST['mode']) ? $_REQUEST['mode'] : 'html';
		if (!empty($action)) {
			switch ($action) {
				case 'big_remove':
					$this->removeBasketItem();
					break;
				
				case 'big_increment':
					$this->setAmountBasketItem(1);
					break;
				
				case 'big_decrement':
					$this->setAmountBasketItem(-1);
					break;
				
				case 'big_quantity':
					$quantity = intval($_REQUEST['quantity']);
					$this->setAmountBasketItem($quantity, true);
					break;
				case 'add_imbir':
					$this->addToBasket(appTools::ORDER_PRODUCT_IMBIR_ID);
					break;
				case 'add_vasabi':
					$this->addToBasket(appTools::ORDER_PRODUCT_VASABI_ID);
					break;
				case 'add_sous':
					$this->addToBasket(appTools::ORDER_PRODUCT_SOUS_ID);
					break;
			}
		}
		
		// прикрепляем свободные ингредиенты к пицце
		$this->fixFreeIngreds();
		
		// получаем запрос на заказ
		$this->getOrderRequest();
		
		// получаем заполненные поля из предыдущего заказа
		$this->getPrevOrderData();
		
		// процессим логику Алле
		$this->processAlleLogic();
		
		// создаем виртуальный заказ и рассчитываем его
		$this->createVirtualOrder();
		
		// валидируем заказ на готовность к оформлению
		$this->validateVirtualOrder();
		
		// оформляем заказ если task == 'order'
		if ($action == 'order') {
			$this->makeOrder();
		}
		
		Sale\Compatible\DiscountCompatibility::revertUsageCompatible();
		
		if ($mode == 'html') {
			$this->includeComponentTemplate();
		} else {
			$this->makeJsonReply();
		}
	}

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

Sale\Compatible\DiscountCompatibility::stopUsageCompatible();
....
Sale\Compatible\DiscountCompatibility::revertUsageCompatible();

Они оборачивают весь наш процессинг заказа. Без них не отработает расчет скидок, произведенных с помощью api d7! Пол дня было потрачено на их поиск) В остальном тут все предельно ясно: если режим отображения $mode == 'html', то мы просто показываем шаблон. Иначе это ajax-запрос, и мы должны выдать информацию.

Пройдемся по функциям:

protected function removeBasketItem()
{
	appTools::removeBasketItem($_REQUEST['basketItemId']);
		
	// обновление допов
	appTools::_addDefaultsToBasket();
}

appTools — это наш "сквозной" класс, в котором собраны функции, используемые в различных местах проекта. Очень полезно бывает таким образом выносить что-то рутинное, что может понадобиться в непредсказуемых местах проекта. Функция собственно удаляет из корзины товар с заданным идентификатором, затем производит "передобавление" товаров, которые должны быть в корзине по умолчанию (имбирь, васаби, соевый соус). Сам код мы здесь не приводим, он построен на стандартных api-функциях d7.

protected function setAmountBasketItem($quantity, $isSet = false)
{
	appTools::setAmountBasketItem($_REQUEST['basketItemId'], $quantity, $isSet);
		
	// обновление допов
	appTools::_addDefaultsToBasket();
}

По аналогии с удалением действуем и в случае изменения количества товаров. Флаг $isSet в данном случае говорит о том, добавлять ли $quantity к уже существующему количеству товара, или же игнорировать его и жестко выставлять $quantity.

function addToBasket($productId, $quantity = 1, $quantityIncrement = true, $properties = [])
{
	$basket = \Bitrix\Sale\Basket::loadItemsForFUser(
		\Bitrix\Sale\Fuser::getId(),
		\Bitrix\Main\Context::getCurrent()->getSite()
	);
	
	// контрольная сумма
	$checkSum = md5(serialize(['id' => $productId, 'properties' => $properties]));
	$properties['checksum'] = [
		'NAME' => 'Контрольная сумма',
		'CODE' => 'checksum',
		'VALUE' => $checkSum,
		'SORT' => 0
	];
	
	$basketItems = $basket->getBasketItems();
	$isEdit = false;
	foreach ($basketItems as $basketItem) {
		$basketPropertyCollection = $basketItem->getPropertyCollection();
		$basketItemProps = $basketPropertyCollection->getPropertyValues();
		
		//print_r($basketItemProps);
		if (!empty($basketItemProps['checksum']) && ($basketItemProps['checksum']['VALUE'] == $checkSum)) {
			$isEdit = true;
			break;
		}
	}
	
	if ($isEdit) {
		// Обновление товара
		if ($quantityIncrement) {
			$basketItem->setField('QUANTITY', $basketItem->getQuantity() + $quantity);
		} else {
			$basketItem->setField('QUANTITY', $quantity);
		}
		$item = $basketItem;
	} else {
		// Добавление товара
		$item = $basket->createItem('catalog', $productId);
		$item->setFields([
			'QUANTITY' => $quantity,
			'CURRENCY' => \Bitrix\Currency\CurrencyManager::getBaseCurrency(),
			'LID' => \Bitrix\Main\Context::getCurrent()->getSite(),
			'PRODUCT_PROVIDER_CLASS' => 'CCatalogProductProvider',
		]);
	}
	
	if (isset($properties)) {
		$basketPropertyCollection = $item->getPropertyCollection();
		$basketPropertyCollection->setProperty($properties);
	}
	
	$basket->save();
}

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

public function fixFreeIngreds()
{
	$basket = Bitrix\Sale\Basket::loadItemsForFUser(\Bitrix\Sale\Fuser::getId(), \Bitrix\Main\Context::getCurrent()->getSite());
	$prevBasketItemId = 0;
	foreach ($basket as $basketItem) {
		$basketPropertyCollection = $basketItem->getPropertyCollection();
		$values = $basketPropertyCollection->getPropertyValues();
		
		if (!empty($values['pizza_id'])) {
			$parentPizza = $basket->getItemById($values['pizza_id']['VALUE']);
			if (empty($parentPizza)) {
				if ($prevBasketItemId == 0) {
					$basketItem->delete();
				} else {
					$basketPropertyCollection->setProperty([
						[
							'NAME' => 'Родитель',
							'CODE' => 'pizza_id',
							'VALUE' => $prevBasketItemId,
							'SORT' => 0,
						],
					]);
					$basketPropertyCollection->save();
					$basketItem->save();
				}
			}
		} else {
			$prevBasketItemId = $basketItem->getId();
		}
		
	}
	$basket->save();
}

При нажатии "повторить заказ" в личном кабинете пользователя, товары из выбранного заказа попадают в текущую корзину с новыми идентификаторами. Соответственно теряется связь ингредиентов и пицц, к которым они принадлежат. Выставляется данная связь с помощью свойства pizza_id, в качестве значения хранящее идентификатор элемента корзины с родительской пиццей. Наша задача отловить такие ситуации и восстановить связи.

protected function getOrderRequest()
{
	global $USER;
	
	// сохраняем запрос в сессии
	if (!empty($_REQUEST['orderRequest'])) {
		$this->orderRequest = $_SESSION['orderRequest'] = $_REQUEST['orderRequest'];
	} else if (!empty($_SESSION['orderRequest'])) {
		$this->orderRequest = $_SESSION['orderRequest'];
	}
	
	// выставляем текущий шаг
	if (!empty($this->orderRequest['step'])) {
		$this->step = $this->orderRequest['step'];
	}
	
	// на первом шаге накопление баллов всегда отключено
	if ($this->step == 1) {
		$this->orderRequest['ballsMode'] = 'off';
	}
	
	// на втором шаге, если явно не указан флаг накопления баллов, включаем баллы
	if ($this->step == 2) {
		if (empty($this->orderRequest['ballsMode'])) {
			$this->orderRequest['ballsMode'] = 'on';
		}
	}
	
	// если пользователь неавторизован отключаем баллы
	if (!$USER->IsAuthorized()) {
		$this->orderRequest['ballsMode'] = 'off';
		$this->orderRequest['email'] = appTools::USER_EXPRESS_EMAIL;
	} else {
		$this->orderRequest['email'] = $USER->GetEmail();
	}
	
	// если баллы отключены снимаем и оплату баллами
	if (!empty($this->orderRequest['ballsMode']) && $this->orderRequest['ballsMode'] == 'off') {
		$this->orderRequest['ballsToPay'] = '';
	}
}

Здесь мы получаем и сохраняем запрос на создание заказа, а также вносим корректировки по баллам.

private function getPrevOrderData()
{
	global $USER;
	
	if ($USER->IsAuthorized()) {
		if (empty($_SESSION['prevOrderProps'])) {
			$orders_r = CSaleOrder::GetList(['ID' => 'DESC'], ['USER_ID' => $USER->getId()], false, [
				'nTopCount' => 1
			]);
			
			$prevOrderProps = [];
			while ($tmp = $orders_r->GetNext()) {
				$orders[$tmp['ID']] = $tmp;
				$prevOrderProps = CSaleOrderPropsValue::GetOrderProps($tmp['ID']);
				
				while ($prop = $prevOrderProps->Fetch()) {
					$propCode = mb_strtolower($prop['CODE']);
					
					if (array_key_exists($propCode, $this->prevOrderData)) {
						$this->prevOrderData[$propCode] = $prop["VALUE"];
					}
				}
			}
			
			$_SESSION['prevOrderProps'] = $this->prevOrderData;
		} else {
			$this->prevOrderData = $_SESSION['prevOrderProps'];
		}
	}
}

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

function processAlleLogic()
{
	// если заказ ко времени то кладем в корзину товар-маркер, иначе убираем
	if (!empty($this->orderRequest['to_time']) && mb_strlen($this->orderRequest['to_time']) > 1) {
		$siteId = \Bitrix\Main\Context::getCurrent()->getSite();
		
		$basket = \Bitrix\Sale\Basket::loadItemsForFUser(
			\Bitrix\Sale\Fuser::getId(),
			$siteId
		);
		
		// значит добавляем в корзину специализированный продукт
		// добавление / обновление
		$basketItems = $basket->getBasketItems();
		$isEdit = false;
		foreach ($basketItems as $basketItem) {
			if ($basketItem->getProductId() == appTools::ORDER_PRODUCT_TO_TIME_ID) {
				$isEdit = true;
				break;
			}
		}
		
		if (!$isEdit) {
			// Добавление товара
			$item = $basket->createItem('catalog', appTools::ORDER_PRODUCT_TO_TIME_ID);
			$item->setFields([
				'QUANTITY' => 1,
				'CURRENCY' => \Bitrix\Currency\CurrencyManager::getBaseCurrency(),
				'LID' => \Bitrix\Main\Context::getCurrent()->getSite(),
				'PRODUCT_PROVIDER_CLASS' => 'CCatalogProductProvider',
			]);
			$properties['hide'] = [
				'NAME' => 'Не показывать в корзине',
				'CODE' => 'hide',
				'VALUE' => true,
				'SORT' => 0
			];
			$basketPropertyCollection = $item->getPropertyCollection();
			$basketPropertyCollection->setProperty($properties);
			$basket->save();
		}
	} else {
		appTools::removeBasketItem(0, appTools::ORDER_PRODUCT_TO_TIME_ID);
	}
	
	// если самовывоз, добавляем купон на самовывоз
	if (!empty($this->orderRequest['delivery_type']) && $this->orderRequest['delivery_type'] == appTools::ORDER_DELIVERY_TYPE_SELFY) {
		if (!empty($this->orderRequest['coupons'])) {
			$this->orderRequest['coupons'][] = appTools::ORDER_SALE_SELFY_CODE;
		} else {
			$this->orderRequest['coupons'] = [appTools::ORDER_SALE_SELFY_CODE];
		}
	}
	
	// если есть выбранный купон, прикрепляем его к расчету
	if (!empty($this->orderRequest['checked_discount'])) {
		if (!empty($this->orderRequest['coupons'])) {
			$this->orderRequest['coupons'][] = $this->orderRequest['checked_discount'];
		} else {
			$this->orderRequest['coupons'] = [$this->orderRequest['checked_discount']];
		}
	}
	
	// если оплата онлайн то системный купон
	if (!empty($this->orderRequest['pay_system']) && $this->orderRequest['pay_system'] == appTools::ORDER_PAY_SYSTEM_ONLINE) {
		if (!empty($this->orderRequest['coupons'])) {
			$this->orderRequest['coupons'][] = appTools::ORDER_SALE_ONLINE_CODE;
		} else {
			$this->orderRequest['coupons'] = [appTools::ORDER_SALE_ONLINE_CODE];
		}
	}
	
	// поправка на баллы вместо скидки
	if ($this->orderRequest['ballsMode'] == 'on') {
		$this->addToBasket(appTools::ORDER_PRODUCT_BALLS_MODE_ID);
	} else {
		appTools::removeBasketItem(0, appTools::ORDER_PRODUCT_BALLS_MODE_ID);
	}
	
	// оплачиваем баллами
	$ballsToPay = intval($this->orderRequest['ballsToPay']);
	if ($ballsToPay > 0) {
		$this->addToBasket(appTools::ORDER_PRODUCT_BALLS_ID, $ballsToPay, false);
		$this->payedByBalls = true;
	} else {
		appTools::removeBasketItem(0, appTools::ORDER_PRODUCT_BALLS_ID);
		$this->payedByBalls = false;
	}
}

А эта функция процессит специфичную для Allepizza логику: системные купоны и оплата баллами. Для собственно оплаты баллами в каталоге товаров создан товар "Оплата баллами" с ценой -1 руб. Поклажа в корзину определенного количества данного товара снижает цену.

protected function createVirtualOrder()
{
	global $USER;
	
	Bitrix\Main\Loader::includeModule("sale");
	Bitrix\Main\Loader::includeModule("catalog");
	
	// создаем виртуальный заказ
	$siteId = \Bitrix\Main\Context::getCurrent()->getSite();
	$userId = $USER->isAuthorized() ? $USER->GetID() : appTools::USER_EXPRESS_ID;
	
	$currencyCode = CurrencyManager::getBaseCurrency();
	
	$this->orderVirtual = \Bitrix\Sale\Order::create($siteId, $userId);
	$this->orderVirtual->setPersonTypeId(appTools::ORDER_PERSON_TYPE_ID);
	$this->orderVirtual->setField('CURRENCY', $currencyCode);
	
	// получаем корзину
	$basketStorage = $this->getBasketStorage();
	$basket = $basketStorage->getBasket();
	
	$result = $basket->refresh();
	if ($result->isSuccess()) {
		$basket->save();
	}
	
	$availableBasket = $basketStorage->getOrderableBasket();
	
	$this->setOrderProps();
	
	// прикрепляем корзину к заказу
	$this->orderVirtual->appendBasket($availableBasket);
	
	// добавляем отгрузки
	$shipmentCollection = $this->orderVirtual->getShipmentCollection();
	
	$shipment = $shipmentCollection->createItem(
		Bitrix\Sale\Delivery\Services\Manager::getObjectById(
			appTools::ORDER_DELIVERY_ID
		)
	);
	
	$shipmentItemCollection = $shipment->getShipmentItemCollection();
	$shipment->setField('CURRENCY', $this->orderVirtual->getCurrency());
	
	foreach ($this->orderVirtual->getBasket()->getOrderableItems() as $item) {
		$shipmentItem = $shipmentItemCollection->createItem($item);
		$shipmentItem->setQuantity($item->getQuantity());
	}
	
	// добавляем оплату
	if (!empty($this->orderRequest['pay_system'])) {
		$paymentCollection = $this->orderVirtual->getPaymentCollection();
		$payment = $paymentCollection->createItem(
			Bitrix\Sale\PaySystem\Manager::getObjectById(
				$this->orderRequest['pay_system']
			)
		);
		$payment->setField("SUM", $this->orderVirtual->getPrice());
		$payment->setField("CURRENCY", $this->orderVirtual->getCurrency());
	}
	
	// применяем купоны
	Sale\DiscountCouponsManager::init(
		Sale\DiscountCouponsManager::MODE_ORDER, [
			"userId" => $this->orderVirtual->getUserId(),
			"orderId" => $this->orderVirtual->getId()
		]
	);
	
	Sale\DiscountCouponsManager::clear();
	
	if (!empty($this->orderRequest['coupons'])) {
		foreach ($this->orderRequest['coupons'] as $coupon) {
			Sale\DiscountCouponsManager::add($coupon);
		}
	}
	
	if (!empty($this->orderRequest['coupons_add'])) {
		foreach ($this->orderRequest['coupons_add'] as $k => $coupon) {
			if (!appTools::isSystemCoupon($coupon)) {
				Sale\DiscountCouponsManager::add($coupon);
			} else {
				unset($this->orderRequest['coupons_add'][$k]);
			}
		}
	}
	
	// рассчитываем скидки отдельно. эти данные нам пригодятся
	$discounts = $this->orderVirtual->getDiscount();
	$discountsRes = $discounts->calculate();
	if ($discountsRes->isSuccess()) {
		$this->discountData = Sale\DiscountCouponsManager::getForApply([]);
	}
	
	// производим финальную обработку
	$this->orderVirtual->doFinalAction(true);
}

Функция создания виртуального заказа. Прикрепляем корзину, отгрузки, оплату. Делаем расчет скидок.

function validateVirtualOrder()
{
	// проверка на половинки пицц
	$halfs = [];
	foreach ($this->orderVirtual->getBasket()->getBasketItems() as $basketItem) {
		/** @var \Bitrix\Sale\BasketItem $basketItem */
		$basketItems[$basketItem->getId()] = $basketItem;
		
		$props_db = CIBlockElement::GetProperty(7, $basketItem->getProductId());
		$props = array();
		
		while ($tmp = $props_db->Fetch()) {
			$props[$tmp['CODE']] = $tmp;
		}
		
		if ($props['part']['VALUE_ENUM'] == '1/2') {
			$halfs['pizza_parts'][$props['size']['VALUE_ENUM']] += $basketItem->getQuantity();
			
			if (isset($props['bort']['VALUE_XML_ID'])) {
				
				if (!in_array($props['bort']['VALUE_XML_ID'], array_keys($halfs['borts']))) {
					$halfs['borts'][$props['bort']['VALUE_XML_ID']] = 0;
				}
				
				$halfs['borts'][$props['bort']['VALUE_XML_ID']] += $basketItem->getQuantity();
			}
		}
	}
	
	$_smMiss = array_values($halfs['pizza_parts'])[0] % 2 <> 0;
	$_bigMiss = array_values($halfs['pizza_parts'])[1] % 2 <> 0;
	if ($_smMiss || $_bigMiss) {
		$this->orderVirtualValidationResult[] = [
			'title' => 'Найдите половинку для пиццы',
			'desc' => "Не хватает " . (($_smMiss && $_bigMiss) ? 'двух половинок' : 'одной половинки') . " пиццы."
		];
	}
	
	// проверка не минимальную сумму
	if ($this->orderRequest['delivery_type'] == appTools::ORDER_DELIVERY_TYPE_NEED_DELIVERY) {
		$props = $this->orderVirtual->getPropertyCollection();
		$propsAr = $props->getArray();
		$orderPrice = $this->orderVirtual->getPrice() - $this->orderVirtual->getDeliveryPrice();
		$minSum = 350;
		
		foreach ($propsAr['properties'] as $prop) {
			if ($prop['CODE'] == 'DOST') {
				$dostId = $prop['VALUE'][0];
			}
		}
		
		if (CModule::IncludeModule('iblock') && !empty($dostId)) {
			$arSort = ["name" => "ASC"];
			$arSelect = ["ID", "NAME", "PROPERTY_minsum", "PROPERTY_delprice"];
			$arFilter = ["IBLOCK_ID" => 9, 'ID' => $dostId];
			$res = CIBlockElement:: GetList($arSort, $arFilter, false, false, $arSelect);
			while ($ob = $res->GetNextElement()) {
				$arFields = $ob->GetFields();
			}
			$minSum = $arFields['PROPERTY_DELPRICE_VALUE'];
		}
		
		if ($orderPrice < $minSum) {
			$this->orderVirtualValidationResult[] = [
				'title' => 'Сумма заказа меньше минимальной',
				'desc' => 'Минимальная сумма заказа составляет ' . $minSum . '₽'
			];
		}
	}
	
	// проверка на location заказа
	if (empty($this->orderRequest['dost'])) {
		$this->orderVirtualValidationResult[] = [
			'title' => 'Не выбран пункт доставки',
			'desc' => 'Пожалуйста, выберите район доставки или пункт самовывоза'
		];
	}
}

Валидация заказа. Тут мы проверяем холостые половинки, минимальную сумму, выбор региона. Складываем все ошибки в массив, чтобы потом выдать их во Front End.

private function makeOrder()
{
	global $USER;
	
	if (!empty($this->orderRequest['komment'])) {
		$this->orderVirtual->setField('USER_DESCRIPTION', $this->orderRequest['komment']);
	}
	
	$this->orderVirtual->save();
	$this->orderCompleted = $this->orderVirtual->getId();
	$_SESSION['orderRequest'] = $_SESSION['prevOrderProps'] = null;
}

Функция сохраняет виртуальный заказ. Перед сохранением перекладываем комментарий пользователя в системное поле.

protected function makeJsonReply()
{
	global $APPLICATION;
	$APPLICATION->RestartBuffer();
	
	// making reply array
	$data = [];
	
	// getting json basket
	$data['basket'] = $this->_jsonBasket();
	
	// getting json order
	$data['order'] = $this->_jsonOrder($data['basket']);
	
	header('Content-Type: application/json');
	echo json_encode($data);
	exit;
}

Всю информацию по заказу нужно передать во фронтэнд в виде json-объекта. Сначала собираем корзину:

protected function _jsonBasket()
{
	$basketItems = $this->orderVirtual->getBasket()->getOrderableItems();
	
	foreach ($basketItems as $basketItem) {
		$productsIdsInCart[] = $basketItem->getProductId();
	}
	
	// получаем информацию о товарах, находящихся в корзине
	if (!empty($productsIdsInCart)) {
		$productsInCart = appTools::getElements([
			"ID" => $productsIdsInCart
		]);
		
		$productsLinkedInCart = appTools::getElements([
			"ID" => $productsIdsInCart
		], 100, [
			'ID', 'IBLOCK_ID', 'PREVIEW_PICTURE',
			'PROPERTY_CML2_LINK.PREVIEW_PICTURE',
			'PROPERTY_CML2_LINK.PROPERTY_BALLS_PERCENT',
			'PROPERTY_CML2_LINK.PROPERTY_CAN_BUY_BY_POINTS',
		]);
	}
	
	// получаем массив товаров для корзины
	$basketItemsVisibleCount = 0;
	$basketArr = [];
	$hasDop = false;
	
	foreach ($basketItems as $k => $basketItem) {
		$itemToArray = [];
		
		$basketPropertyCollection = $basketItem->getPropertyCollection();
		$basketItemProps = $basketPropertyCollection->getPropertyValues();
		
		$basketItemsVisibleCount++;
		
		$itemToArray['id'] = $basketItem->getField('ID');
		$itemToArray['productId'] = $productId = $basketItem->getProductId();
		$itemToArray['quantity'] = $basketItem->getQuantity();
		
		
		// изображение товара
		$itemToArray['picture'] = $productsInCart[$productId]['fields']['PREVIEW_PICTURE'];
		
		// картинка из товара-родителя для торговых предложений
		if (!empty($productsLinkedInCart[$productId]) && !empty($productsLinkedInCart[$productId]['fields']['PROPERTY_CML2_LINK_PREVIEW_PICTURE'])) {
			$itemToArray['picture'] = $productsLinkedInCart[$productId]['fields']['PROPERTY_CML2_LINK_PREVIEW_PICTURE'];
		}
		
		if (!empty($itemToArray['picture'])) {
			$src = CFile::GetPath($itemToArray['picture']);
		} else {
			$src = '/bitrix/templates/alle/components/alle/catalog.section/.default/images/no_photo.png';
		}
		
		$itemToArray['picture'] = $src;
		
		// баллы
		$ballsToAddPercent = 0;
		$ballsAllowed = '0';
		
		// только если нет скидок
		if ($basketItem->getDiscountPrice() == 0) {
			$ballsAllowed = true;
		}
		
		// добавляет ли товар баллы?
		if ($productsInCart[$productId]['props']['BALLS_PERCENT']['VALUE']) {
			$ballsToAddPercent = $productsInCart[$productId]['props']['BALLS_PERCENT']['VALUE'];
		} elseif ($productsLinkedInCart[$productId]['fields']['PROPERTY_CML2_LINK_PROPERTY_BALLS_PERCENT_VALUE']) {
			$ballsToAddPercent = $productsLinkedInCart[$productId]['fields']['PROPERTY_CML2_LINK_PROPERTY_BALLS_PERCENT_VALUE'];
		}
		
		$itemToArray['canPayBalls'] = $productsInCart[$productId]['props']['CAN_BUY_BY_POINTS']['VALUE'] == 'да';
		
		$itemToArray['ballsToAddPercent'] = $ballsToAddPercent;
		$itemToArray['ballsAllowed'] = $ballsAllowed;
		
		// ищем дочерние связанные ингредиенты для пиццы
		$ingredients = appTools::getIngredientsForBasketItem($basketItem->getField('ID'), $basketItems);
		$itemToArray['ingredients'] = $ingredients;
		
		$itemToArray['name'] = $basketItem->getField('NAME');
		
		// price
		if ($basketItem->getDiscountPrice() == 0) {
			if (!empty($ingredients['items'])) {
				$itemToArray['price'] = CCurrencyLang::CurrencyFormat($basketItem->getPrice() + $ingredients['prices']['PRICE'], 'RUB');
				$itemToArray['price_num'] = $basketItem->getPrice() + $ingredients['prices']['PRICE'];
			} else {
				$itemToArray['price'] = CCurrencyLang::CurrencyFormat($basketItem->getPrice(), 'RUB');
				$itemToArray['price_num'] = $basketItem->getPrice();
			}
		} else {
			if (!empty($ingredients['items'])) {
				$itemToArray['price'] = CCurrencyLang::CurrencyFormat($basketItem->getPrice() + $ingredients['prices']['PRICE'], 'RUB');
				$itemToArray['price_num'] = $basketItem->getPrice() + $ingredients['prices']['PRICE'];
			} else {
				$itemToArray['price'] = CCurrencyLang::CurrencyFormat($basketItem->getPrice(), 'RUB');
				$itemToArray['price_num'] = $basketItem->getPrice();
			}
			if (!empty($ingredients['items'])) {
				$itemToArray['price_old'] = CCurrencyLang::CurrencyFormat($basketItem->getBasePrice() + $ingredients['prices']['BASE_PRICE'], 'RUB');
				$itemToArray['price_old_num'] = $basketItem->getBasePrice() + $ingredients['prices']['BASE_PRICE'];
			} else {
				$itemToArray['price_old'] = CCurrencyLang::CurrencyFormat($basketItem->getBasePrice(), 'RUB');
				$itemToArray['price_old_num'] = $basketItem->getBasePrice();
			}
		}
		
		if (!empty($ingredients['items'])) {
			$itemToArray['price_base'] = CCurrencyLang::CurrencyFormat($basketItem->getBasePrice() + $ingredients['prices']['BASE_PRICE'], 'RUB');
			$itemToArray['price_base_num'] = $basketItem->getBasePrice() + $ingredients['prices']['BASE_PRICE'];
		} else {
			$itemToArray['price_base'] = CCurrencyLang::CurrencyFormat($basketItem->getBasePrice(), 'RUB');
			$itemToArray['price_base_num'] = $basketItem->getBasePrice();
		}
		
		$itemToArray['summPrice'] = CCurrencyLang::CurrencyFormat($itemToArray['price'] * $basketItem->getQuantity(), 'RUB');
		$itemToArray['summPriceNum'] = $itemToArray['price'] * $basketItem->getQuantity();
		$itemToArray['summBasePriceNum'] = $itemToArray['price_base_num'] * $basketItem->getQuantity();
		
		if (!empty($itemToArray['price_old_num'])) {
			$itemToArray['summPriceOld'] = CCurrencyLang::CurrencyFormat($basketItem->getQuantity() * $itemToArray['price_old_num'], 'RUB');
		}
		
		$itemToArray['props'] = $basketItemProps;
		$itemToArray['props_product'] = $productsInCart[$productId]['props'];
		$itemToArray['fields_product'] = $productsInCart[$productId]['fields'];
		$itemToArray['is_pizzas_child'] = !empty($basketItemProps['pizza_id']);
		$itemToArray['is_dop'] = !empty($basketItemProps['dop']);
		$itemToArray['hide'] = !empty($basketItemProps['hide']);
		
		if (!empty($basketItemProps['dop'])) {
			$hasDop = true;
		}
		
		$basketArr[] = $itemToArray;
	}
	
	return compact('basketArr', 'hasDop');
}

Затем данные по заказу:

protected function _jsonOrder($basket)
{
	global $USER;
	
	$order = [];
	
	// getting order delivery types
	$order['settings']['delivery_types'] = [
		appTools::ORDER_DELIVERY_TYPE_NEED_DELIVERY => 'Нужна доставка',
		appTools::ORDER_DELIVERY_TYPE_SELFY => 'Заберу сам (-10%)'
	];
	
	$order['delivery_type'] = !empty($this->orderRequest['delivery_type']) ? $this->orderRequest['delivery_type'] : appTools::ORDER_DELIVERY_TYPE_NEED_DELIVERY;
	
	// getting dost
	$dostItems = appTools::getElements(['IBLOCK_ID' => appTools::DOST_IBLOCK_ID, 'ACTIVE' => 'Y']);
	$dostSects = appTools::getRubrics(['IBLOCK_ID' => appTools::DOST_IBLOCK_ID, 'ACTIVE' => 'Y']);
	$dosts = $dostsList = [];
	
	foreach ($dostItems as $dostItem) {
		if ($dostItem['fields']['IBLOCK_SECTION_ID']) {
			$dosts[appTools::ORDER_DELIVERY_TYPE_NEED_DELIVERY][$dostSects[$dostItem['fields']['IBLOCK_SECTION_ID']]['NAME']][$dostItem['fields']['ID']] = $dostItem['fields']['NAME'];
		} else {
			$dosts[appTools::ORDER_DELIVERY_TYPE_SELFY][0][$dostItem['fields']['ID']] = $dostItem['fields']['NAME'];
		}
		$dostsList[$dostItem['fields']['ID']] = $dostItem['fields']['NAME'];
	}
	$order['settings']['dosts'] = $dosts;
	$order['settings']['dostsList'] = $dostsList;
	$order['dost'] = !empty($this->orderRequest['dost']) ? $this->orderRequest['dost'] : 0;
	
	// to time
	$order['settings']['to_time_list'] = [
		'11:00', '11:30', '12:00', '12:30', '13:00', '13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30',
		'17:00', '17:30', '18:00', '18:30', '19:00', '19:30', '20:00', '20:30', '21:00', '21:30', '22:00', '22:30', '23:00',
	];
	$order['to_time'] = !empty($this->orderRequest['to_time']) ? $this->orderRequest['to_time'] : 0;
	
	// getting paysystems
	$rsPaySystem = \Bitrix\Sale\Internals\PaySystemActionTable::getList([
		'filter' => ['ACTIVE' => 'Y'],
	]);
	
	while ($arPaySystem = $rsPaySystem->fetch()) {
		$order['settings']['pay_systems'][] = $arPaySystem;
	}
	$order['pay_system'] = !empty($this->orderRequest['pay_system']) ? $this->orderRequest['pay_system'] : $order['settings']['pay_systems'][0]['ID'];
	
	// getting system coupons
	$order['settings']['sales_list'][appTools::ORDER_SALE_OFFICE_CODE] = appTools::ORDER_SALE_OFFICE_NAME;
	$order['settings']['sales_list'][appTools::ORDER_SALE_HAPPY_BIRTHDAY_CODE] = appTools::ORDER_SALE_HAPPY_BIRTHDAY_NAME;
	
	// getting happy hours info
	$happyHoursDiscount = CSaleDiscount::GetByID(appTools::ORDER_SALE_HAPPY_HOURS_ID);
	$order['settings']['happy_hours_active'] = ($happyHoursDiscount['ACTIVE'] == 'Y' && $order['to_time'] == 0);
	$order['settings']['happy_hours_name'] = appTools::ORDER_SALE_HAPPY_HOURS_NAME;
	
	// getting checked discounts
	$order['checked_discount'] = !empty($this->orderRequest['checked_discount']) ? $this->orderRequest['checked_discount'] : '';
	
	// getting order price formated
	$order['price'] = CCurrencyLang::CurrencyFormat($this->orderVirtual->getPrice() - $this->orderVirtual->getDeliveryPrice(), 'RUB');
	$order['price_base'] = CCurrencyLang::CurrencyFormat($this->orderVirtual->getPrice() - $this->orderVirtual->getDeliveryPrice() + $this->_getOrderDiscountPrice(), 'RUB');
	
	// delivery price
	$order['delivery_price'] = CCurrencyLang::CurrencyFormat($this->orderVirtual->getDeliveryPrice(), 'RUB');
	
	// full summ price
	$order['full_price'] = CCurrencyLang::CurrencyFormat($this->orderVirtual->getPrice(), 'RUB');
	$order['discount_price'] = CCurrencyLang::CurrencyFormat($this->_getOrderDiscountPrice(), 'RUB');
	
	$order['coupons'] = !empty($this->orderRequest['coupons']) ? $this->orderRequest['coupons'] : [];
	$order['coupons_add'] = !empty($this->orderRequest['coupons_add']) ? $this->orderRequest['coupons_add'] : [];
	
	$order['coupons_applied'] = $this->discountData;
	
	$order['order_validation_result'] = $this->orderVirtualValidationResult;
	
	$order['step'] = $this->step;
	
	$order['user']['have_balls_num'] = 0;
	$order['user']['authorized'] = $USER->IsAuthorized();
	if ($order['user']['authorized']) {
		$user_db = CUser::GetByID($USER->GetID());
		$user_ar = $user_db->Fetch();
		$order['user']['have_balls_num'] = $user_ar['UF_BALLS'];
		$order['user']['have_balls'] = $user_ar['UF_BALLS'] . ' ' . appTools::plural_form($user_ar['UF_BALLS'], ['балл', 'балла', 'баллов']);;
	} else {
		$order['user']['have_balls'] = '0 ' . appTools::plural_form(0, ['балл', 'балла', 'баллов']);
	}
	
	$order['balls_mode'] = !empty($this->orderRequest['ballsMode']) ? $this->orderRequest['ballsMode'] : 'on';
	
	$order['balls_cashback'] = $this->_getOrderBallsCashback($basket) . ' ' . appTools::plural_form($this->_getOrderBallsCashback($basket), ['балл', 'балла', 'баллов']);
	$order['balls_max_can_pay_num'] = $this->_getOrderMaxBallsCanPay($basket, $order['user']['have_balls_num']);
	$order['balls_max_can_pay'] = $order['balls_max_can_pay_num'] . ' ' . appTools::plural_form($order['balls_max_can_pay_num'], ['балл', 'балла', 'баллов']);
	
	$order['balls_to_pay'] = !empty($this->orderRequest['ballsToPay']) ? $this->orderRequest['ballsToPay'] : '';
	$order['payed_by_balls'] = $this->payedByBalls;
	
	// поля персональных данных
	$order['fio'] = !empty($this->orderRequest['fio']) ? $this->orderRequest['fio'] :
		(!empty($this->prevOrderData['fio']) ? $this->prevOrderData['fio'] : '');
	$order['phone'] = !empty($this->orderRequest['phone']) ? $this->orderRequest['phone'] :
		!empty($this->prevOrderData['phone']) ? $this->prevOrderData['phone'] : '';
	$order['street'] = !empty($this->orderRequest['street']) ? $this->orderRequest['street'] :
		!empty($this->prevOrderData['street']) ? $this->prevOrderData['street'] : '';
	$order['dom'] = !empty($this->orderRequest['dom']) ? $this->orderRequest['dom'] :
		!empty($this->prevOrderData['dom']) ? $this->prevOrderData['dom'] : '';
	$order['podezd'] = !empty($this->orderRequest['podezd']) ? $this->orderRequest['podezd'] :
		!empty($this->prevOrderData['podezd']) ? $this->prevOrderData['podezd'] : '';
	$order['etaj'] = !empty($this->orderRequest['etaj']) ? $this->orderRequest['etaj'] :
		!empty($this->prevOrderData['etaj']) ? $this->prevOrderData['etaj'] : '';
	$order['kvartofis'] = !empty($this->orderRequest['kvartofis']) ? $this->orderRequest['kvartofis'] :
		!empty($this->prevOrderData['kvartofis']) ? $this->prevOrderData['kvartofis'] : '';
	$order['person_count'] = !empty($this->orderRequest['person_count']) ? $this->orderRequest['person_count'] : '';
	$order['cashback'] = !empty($this->orderRequest['cashback']) ? $this->orderRequest['cashback'] : '';
	$order['komment'] = !empty($this->orderRequest['komment']) ? $this->orderRequest['komment'] : '';
	
	$order['completed'] = $this->orderCompleted;
	
	// getting vosnovas
	$osnovasItems = appTools::getElements(['IBLOCK_ID' => appTools::VOK_OSNOVA_IBLOCK_ID, 'ACTIVE' => 'Y']);
	$osnovas = [];
	foreach ($osnovasItems as $osnovasItem) {
		$osnovas[$osnovasItem['fields']['ID']] = $osnovasItem['fields']['NAME'];
	}
	$order['settings']['osnovas'] = $osnovas;
	
	//print_r($this->discountData);
	
	return $order;
}

Вот такой получился код Back end. ~1100 строк кода. Переходим к Front end.

Front end.

Разметка для отображения первого шага получилась такая:

<div v-if="hasInfo && info.order.step == 1" class="col bg-white mb-3">
	<div class="section-header d-flex align-items-center flex-wrap mb-2">
		<div class="h1 flex-grow-1">Ваша корзина</div>
	</div>
	<div class="cart">
		<div class="cart__nav">
			<a class="cart__nav__item active" href="javascript:void(0)">
				<span class="cart__nav__item__badge">
					<span>Корзина</span>
					<i class="fas fa-arrow-right"></i>
				</span>
			</a>
			<a class="cart__nav__item" href="javascript:void(0)">
				<span class="cart__nav__item__badge">
					<span>Баллы <span class="d-none d-sm-inline-flex">и скидки</span></span>
					<i class="fas fa-arrow-right"></i>
				</span>
			</a>
			<a class="cart__nav__item" href="javascript:void(0)">
				<span class="cart__nav__item__badge"><span>Данные</span></span>
			</a>
		</div>
	</div>
	<div class="cart__row">
		<div class="cart__col-left">
			<div class="cart__list">
				<div class="cart__list__header">
					<div class="cart__list__col cart__list__col_img">&nbsp;</div>
					<div class="cart__list__col cart__list__col_dishes">Ваши блюда</div>
					<div class="cart__list__col cart__list__col_amount">Кол-во</div>
					<div class="cart__list__col cart__list__col_price">Цена</div>
					<div class="cart__list__col cart__list__col_trash"><i
								class="far fa-trash-alt"></i></div>
				</div>
				<div class="cart__list__items">
					
					
					<cart-item
							v-if="!item.is_pizzas_child && !item.is_dop && !item.hide"
							v-for="item in info.basket.basketArr"
							:key="item.id"
							:item="item"
							:settings="info.order.settings"
							@item-refreshed="handleItemRefreshed"
					></cart-item>
				
				
				</div>
			</div>
			<div v-if="info.basket.hasDop" class="cart__dop">
				<div class="h4">Дополнительно в вашем заказе</div>
				<div class="cart__dop__items">
					<div v-if="item.is_dop"
					     v-for="item in info.basket.basketArr"
					     class="cart__dop__item">
						<div class="cart__dop__item__img">
							<img :src="'https://i.allepizza.ru' + item.picture">
						</div>
						<div class="cart__dop__item__title">
							{{item.name}}
						</div>
						<div class="cart__dop__item__param">
							{{getWeightLabel(item)}}
						</div>
						<div class="cart__dop__item__value">
							{{getWeightValue(item)}}
						</div>
					</div>
				</div>
			</div>
			<div v-if="info.order.price_base != info.order.price" class="cart__subresult pb-0">
				<div class="cart__subresult__label fs16i">Сумма без скидки:</div>
				<div class="cart__subresult__price fs18i">
					{{info.order.price_base}}
				</div>
			</div>
			<div class="cart__subresult border-0 mt-0 pt-0">
				<div v-if="info.order.price_base != info.order.price" class="cart__subresult__label">
					Сумма со скидкой:
				</div>
				<div v-else class="cart__subresult__label">Сумма:</div>
				<div class="cart__subresult__price">
					{{info.order.price}}
				</div>
			</div>
		</div>
		<div class="cart__col-right d-flex flex-column">
			<div class="cart__settings">
				<div class="h3">Район доставки:</div>
				<div class="cart__delivery-checker">
					<div v-for="deliveryType, deliveryTypeId in info.order.settings.delivery_types"
					     class="custom-control custom-checkbox">
						<input class="custom-control-input" type="radio" name="delivery_type"
						       :value="deliveryTypeId"
						       @change="handleDeliveryTypeChange(deliveryTypeId)"
						       :id="'customCheckDeliveryType' + deliveryTypeId"
						       :checked="(info.order.delivery_type == deliveryTypeId)?'checked':''">
						<label class="custom-control-label"
						       :for="'customCheckDeliveryType' + deliveryTypeId">
							{{deliveryType}}
						</label>
					</div>
				</div>
				<div class="cart__delivery-list">
					<select v-model="info.order.dost" v-if="dostsHasGroups" @change="handleParamChanged()"
					        class="custom-select custom-select_alle">
						<option value="0" selected>Выберите район доставки</option>
						<optgroup v-for="dostsItems, dostsGroupId in dosts" :label="dostsGroupId">
							{{dostsGroupId}}
							<option v-for="dostsItem, dostsItemId in dostsItems" :value="dostsItemId">
								{{dostsItem}}
							</option>
						</optgroup>
					</select>
					<select v-model="info.order.dost" v-if="!dostsHasGroups" @change="handleParamChanged()"
					        class="custom-select custom-select_alle">
						<option value="0" selected>Выберите район доставки</option>
						<option v-for="dostsItem, dostsItemId in dosts[0]" :value="dostsItemId">
							{{dostsItem}}
						</option>
					</select>
				</div>
				<div class="cart__delivery-time">
					<div class="cart__delivery-time__label">Заказ ко времени</div>
					<div class="cart__delivery-time__list">
						<select v-model="info.order.to_time" @change="handleParamChanged()"
						        class="custom-select custom-select_alle">
							<option value="0" selected>Как можно скорее</option>
							<option v-for="to_time_item in info.order.settings.to_time_list">
								{{to_time_item}}
							</option>
						</select>
					</div>
				</div>
				<div class="h3">Способ оплаты:</div>
				<div class="cart__payment">
					<div class="btn-group-toggle btn-group-toggle_incart">
						<label v-for="pay_system_item in info.order.settings.pay_systems"
						       class="btn btn-tag btn-tag_incart"
						       @click="handlePaySystemChanged(pay_system_item.ID)"
						       :class="{'active': pay_system_item.ID == info.order.pay_system}">
							{{pay_system_item.NAME}}
						</label>
					</div>
				</div>
				<div class="h3">Доступные скидки:</div>
				<div class="cart__payment">
					<div class="btn-group-toggle btn-group-toggle_incart">
						<label v-if="!info.order.settings.happy_hours_active"
						       v-for="sale_item, sale_item_code in info.order.settings.sales_list"
						       @click="handleAllowableDiscountCheck(sale_item_code)"
						       class="btn btn-tag btn-tag_incart"
						       :class="{'active': (info.order.checked_discount == sale_item_code)}">
							{{sale_item}}
						</label>
						<label class="btn btn-tag btn-tag_incart disabled"
						       :class="{'active': info.order.settings.happy_hours_active}">
							{{info.order.settings.happy_hours_name}}
						</label>
					</div>
				</div>
				<div class="h3">Купон:</div>
				<div class="mincart__coupon">
					<div class="mincart__coupon__input mincart__coupon__input_incart">
						<input v-model="coupon_add" class="form-control" type="text">
					</div>
					<button @click="handleCouponAdd()" class="btn mincart__coupon__btn">Применить</button>
				</div>
				<div :class="{'mt-2': info.order.coupons_add.length}">
					<span v-for="coupon in info.order.coupons_add"
					      :class="{
					        'badge-success': !!info.order.coupons_applied[coupon],
					        'badge-danger': !!!info.order.coupons_applied[coupon],
					        }"
					      class="badge badge-pill fs14 mr-2">
						<i :class="{
					        'fa-check-circle': !!info.order.coupons_applied[coupon],
					        'fa-minus-circle': !!!info.order.coupons_applied[coupon],
					        }"
						   class="fas"></i>&nbsp;{{coupon}}&nbsp;<i @click="handleCouponDelete(coupon)"
						                                            class="fas fa-times"></i>
					</span>
				</div>
			</div>
			<div class="cart__result-wrapper">
				<div class="cart__result">
					<div class="cart__result__line cart__result__line_delivery">
						<span class="cart__result__line__label">Доставка:</span>
						<span class="cart__result__line__value">{{info.order.delivery_price}}</span>
					</div>
					<div class="cart__result__line cart__result__line_full">
						<span class="cart__result__line__label">Итого:</span>
						<span class="cart__result__line__value">{{info.order.full_price}}</span>
					</div>
					<div class="cart__result__button item__btn">
						<span @click="handleNextStep()"
						      class="btn btn-alle btn-tocart btn-tocart_simple btn-tocart_big">Далее</span>
					</div>
				</div>
			</div>
		</div>
	</div>
</div>

<cart-item> — это Vue.js-компонент для вывода товара в корзине. Приводить его код не будем, там чистая механика. Перейдем к JS.

(function ($) {
	
	makeOrder = {};
	makeOrder.item = {};
	
	$(document).ready(function () {
		makeOrder.init();
		App._showSpinner();
	});
	
	makeOrder.init = function () {
		makeOrder.item = new Vue({
			el: '#makeOrder',
			data: {
				info: [],
				hasInfo: false,
				coupon_add: '',
				coupons_add: [],
				personalHasError: {
					fio: false,
					phone: false,
					street: false,
					dom: false,
				}
			},
			methods: {
				getData(task, params = {}) {
					var request = '',
						dataPost = {
							'mode': 'ajax',
							'action': task,
							'orderRequest': {}
						},
						that = this;
					
					Object.keys(params).map(function (k) {
						dataPost[k] = params[k];
					});
					
					if (!!this.info.order) {
						dataPost['orderRequest']['dost'] = this.info.order.dost;
						dataPost['orderRequest']['delivery_type'] = this.info.order.delivery_type;
						dataPost['orderRequest']['to_time'] = this.info.order.to_time;
						dataPost['orderRequest']['pay_system'] = this.info.order.pay_system;
						dataPost['orderRequest']['checked_discount'] = this.info.order.checked_discount;
						dataPost['orderRequest']['coupons_add'] = this.info.order.coupons_add;
						dataPost['orderRequest']['step'] = this.info.order.step;
						dataPost['orderRequest']['ballsMode'] = this.info.order.balls_mode;
						dataPost['orderRequest']['ballsToPay'] = this.info.order.balls_to_pay;
						dataPost['orderRequest']['fio'] = this.info.order.fio;
						dataPost['orderRequest']['phone'] = this.info.order.phone;
						dataPost['orderRequest']['street'] = this.info.order.street;
						dataPost['orderRequest']['dom'] = this.info.order.dom;
						dataPost['orderRequest']['podezd'] = this.info.order.podezd;
						dataPost['orderRequest']['etaj'] = this.info.order.etaj;
						dataPost['orderRequest']['kvartofis'] = this.info.order.kvartofis;
						dataPost['orderRequest']['person_count'] = this.info.order.person_count;
						dataPost['orderRequest']['cashback'] = this.info.order.cashback;
						dataPost['orderRequest']['komment'] = this.info.order.komment;
						dataPost['orderRequest']['raion'] = this.info.order.settings.dostsList[this.info.order.dost];
					}
					
					App._showSpinner();
					
					$.ajax({
						type: 'POST',
						//contentType: 'application/json; charset=utf-8',
						url: "/cart/?" + request,
						data: dataPost
					}).done(function (data) {
						App._hideSpinner();
						that.info = data;
						that.hasInfo = true;
						
						if (data.order.completed > 0) {
							window.location.href = '/cart/order/?ID=' + data.order.completed;
						}
						
						setTimeout(function () {
							if (!$('#orderPhone').hasClass('masked')) {
								$('#orderPhone').mask("+7 (999) 999-99-99");
								$('#orderPhone').val(that.info.order.phone);
							}
							
							$('#orderPhone').addClass('masked');
						}, 100);
					});
					
				},
				handleItemRefreshed(params) {
					this.getData(params.task, params);
				},
				getWeightLabel(item) {
					var result = 'Кол-во';
					
					if (!!item.props && !!item.props.aweight) {
						result = item.props.aweight.NAME;
					}
					
					return result;
				},
				getWeightValue(item) {
					var result = item.quantity + 'шт';
					
					if (!!item.props && !!item.props.aweight) {
						result = item.props.aweight.VALUE;
					}
					
					return result;
				},
				handleDeliveryTypeChange(deliveryTypeId) {
					var checked = $('.cart__delivery-checker input:checked').val();
					this.info.order.delivery_type = checked;
					this.info.order.dost = 0;
					
					this.getData('view');
				},
				handlePaySystemChanged(paySystemId) {
					this.info.order.pay_system = paySystemId;
					
					this.getData('view');
				},
				handleAllowableDiscountCheck(sale_item_code) {
					if (this.info.order.checked_discount == sale_item_code) {
						this.info.order.checked_discount = '';
					} else {
						this.info.order.checked_discount = sale_item_code;
					}
					
					this.getData('view');
				},
				handleParamChanged() {
					this.getData('view');
				},
				handleCouponAdd() {
					if (this.coupon_add.length) {
						if (!this.info.order.coupons_add.includes(this.coupon_add)) {
							this.info.order.coupons_add.push(this.coupon_add);
						}
						this.coupon_add = '';
						
						this.getData('view');
					}
				},
				handleCouponDelete(coupon) {
					this.info.order.coupons_add = this.info.order.coupons_add.filter(function (value, index, arr) {
						return value !== coupon;
					});
					
					this.getData('view');
				},
				handleNextStep() {
					if (Object.keys(this.info.order.order_validation_result).length > 0) {
						$('#infoModal .body-inner-box').html($('#errorToInfoModal').html());
						$('#infoModal').modal('show');
					} else {
						this.info.order.step = 2;
						
						if (this.info.order.price == this.info.order.price_base) {
							this.info.order.balls_mode = 'on';
						}
						
						this.getData('view');
					}
					this._scrollToTop();
				},
				handleFinalStep() {
					this.info.order.step = 3;
					this.getData('view');
					this._scrollToTop();
				},
				handlePrevStep(step) {
					this.info.order.step = step;
					this.getData('view');
					this._scrollToTop();
				},
				handleBallsModeOn() {
					this.info.order.balls_mode = 'on';
					this.getData('view');
				},
				handleBallsModeOff() {
					this.info.order.balls_mode = 'off';
					this.getData('view');
				},
				handleBallsPay() {
					if (parseInt(this.info.order.balls_to_pay) > parseInt(this.info.order.balls_max_can_pay_num)) {
						this.info.order.balls_to_pay = parseInt(this.info.order.balls_max_can_pay_num);
					}
					this.getData('view');
				},
				handleBallsPayDecline() {
					this.info.order.balls_to_pay = '';
					this.getData('view');
				},
				handleOrderFinish() {
					var canOrder = true;
					this.personalHasError.fio = false;
					this.personalHasError.phone = false;
					this.personalHasError.street = false;
					this.personalHasError.dom = false;
					
					if (this.info.order.fio.trim() == '') {
						canOrder = false;
						this.personalHasError.fio = true;
					}
					
					this.info.order.phone = $('#orderPhone').val();
					console.log(this.info.order.phone);
					if (this.info.order.phone.trim() == '') {
						canOrder = false;
						this.personalHasError.phone = true;
					}
					
					if (this.info.order.delivery_type == '1' && this.info.order.street.trim() == '') {
						canOrder = false;
						this.personalHasError.street = true;
					}
					
					if (this.info.order.delivery_type == '1' && this.info.order.dom.trim() == '') {
						canOrder = false;
						this.personalHasError.dom = true;
					}
					
					if (canOrder) {
						this.getData('order');
					}
				},
				handlePhoneChanged() {
					this.info.order.phone = $('#orderPhone').val();
				},
				_scrollToTop() {
					$([document.documentElement, document.body]).animate({
						scrollTop: $(".h1").offset().top
					}, 500);
				}
			},
			computed: {
				hasDop: function () {
					var has = false;
				},
				dosts: function () {
					if (!!this.info.order.delivery_type) {
						var dosts = this.info.order.settings.dosts[this.info.order.delivery_type];
						
						return dosts;
					}
					return {};
				},
				dostsHasGroups: function () {
					return Object.keys(this.dosts).length > 1;
				}
			},
			created() {
				App._showSpinner();
			},
			mounted() {
				this.getData('view');
				$(this.$el).removeClass('hidden');
			}
		})
		;
		
		
	};
	
}(jQuery));

"И это весь JS?" — спросите вы. Да! 266 строчек. Всего лишь. Волшебный Vue.js. Что же тут происходит? Давайте немного разберемся:

Структурно основной точкой обмена информацией является функция getData. Она отправляет запрос и получает от Back end json-объект. В mounted() мы делаем инициирующий вызов getData, который снабжает нас всеми необходимыми данными. 

Дальше идет серия обслуживающих хэндлеров, которые в конечном счете опять обращаются с запросом к бэкенду с помощью все той же getData. Всю манипуляцию с Dom берет на себя Vue.js, нам остается только сконцентрироваться на отправке / получении правильных данных.

Шаг 2: Баллы или скидка.

Front end.

Визуальное представление второго шага обеспечивает разметка:

<div v-if="hasInfo && info.order.step == 2" class="col bg-white mb-3">
	<div class="section-header d-flex align-items-center flex-wrap mb-2">
		<div class="h1 flex-grow-1">Ваша корзина</div>
	</div>
	<div class="cart">
		<div class="cart__nav">
			<a class="cart__nav__item" href="javascript:void(0)">
				<span class="cart__nav__item__badge"><span>Корзина</span>
					<i class="fas fa-arrow-right"></i>
				</span>
			</a>
			<a class="cart__nav__item active" href="javascript:void(0)">
				<span class="cart__nav__item__badge">
					<span>Баллы <span class="d-none d-sm-inline-flex">и скидки</span></span>
					<i class="fas fa-arrow-right"></i>
				</span>
			</a>
			<a class="cart__nav__item" href="javascript:void(0)">
				<span class="cart__nav__item__badge"><span>Данные</span></span>
			</a>
		</div>
	</div>
	<div class="cart__row">
		<div class="cart__col-full">
			<? /** НЕавторизованный пользователь */ ?>
			<div v-if="!info.order.user.authorized" class="cart__col-balls">
				<div class="cart__col-balls__box">
					<div class="balls-h">Баллы доступны только зарегистрированным пользователям</div>
					
					<div class="fs16">
						<a href="/register/" class="theme-colored-link"><b>Зарегистрируйтесь</b></a> на
						сайте, если
						вы делаете заказ впервые
					</div>
					<div class="fs16 mb-3">
						<a href="/login/?backurl=%2Fcart%2F" class="theme-colored-link"><b>Войдите в Личный
								Кабинет</b></a>, если вы
						уже зарегистрированы
					</div>
					
					<div class="balls-info theme-color">Баллами можно оплачивать до 50% заказа!</div>
					<div class="balls-info balls-info_secondary"><span
								class="text-nowrap mr-3 d-inline-flex">1 балл = 1 рубль</span><a
								class="theme-colored-link dashed-underline fs14 text-nowrap" href="/">Подробнее
							о правилах</a></div>
					<div class="balls-checker-header">Выберите:</div>
					<div class="balls-result">
						<ul class="nav nav-pills nav-pills-balls mb-3" id="pills-tab">
							<li class="nav-item">
								<a class="custom-control-label nav-link disabled" id="pills-home-tab">
									Начислить баллы
								</a>
							</li>
							<li class="nav-item">
								<a class="custom-control-label nav-link active" id="pills-profile-tab">
									Получить скидки
								</a>
							</li>
						</ul>
						<div class="tab-content" id="pills-tabContent">
							<div class="tab-pane fade" id="pills-home" role="tabpanel"
							     aria-labelledby="pills-home-tab">
								<p class="balls-result__box"><span class="mr-2 d-inline-flex">за заказ будет начислено:</span><span
											class="theme-color text-nowrap">+37 баллов</span></p>
								<p class="theme-color fs20">Внимание! Баллы отменяют все скидки и купоны при
									зачислении и списании. При оплате баллами баллы за заказ не
									начисляются.</p>
							</div>
							<div class="tab-pane fade show active" id="pills-profile" role="tabpanel"
							     aria-labelledby="pills-profile-tab">
								<p class="balls-result__box">
									<span class="mr-2">к заказу применится скидка:</span>
									<span class="theme-color text-nowrap">–{{info.order.discount_price}}</span>
								</p>
								<p class="theme-color fs20">Внимание! Скидки отменяют баллы!</p>
							</div>
						</div>
					</div>
				</div>
			</div>
			
			<? /** авторизованный пользователь */ ?>
			<div v-if="info.order.user.authorized" class="cart__col-balls">
				<div class="cart__col-balls__box">
					<div class="balls-h">На счету доступно <b>{{info.order.user.have_balls}}</b></div>
					<div class="balls-info balls-info_secondary"><span
								class="text-nowrap mr-3 d-inline-flex">1 балл = 1 рубль</span><a
								class="theme-colored-link dashed-underline fs14 text-nowrap" href="/">Подробнее
							о правилах</a></div>
					<div class="balls-checker-header">Выберите:</div>
					<div class="balls-result">
						<ul class="nav nav-pills nav-pills-balls mb-3">
							<li class="nav-item">
								<a class="custom-control-label nav-link"
								   @click="handleBallsModeOn()"
								   :class="{'active': (info.order.balls_mode == 'on')}"
								   href="javascript:void(0)">
									Воспользоваться баллами
								</a>
							</li>
							<li class="nav-item">
								<a class="custom-control-label nav-link"
								   @click="handleBallsModeOff()"
								   :class="{'active': (info.order.balls_mode == 'off')}"
								   href="javascript:void(0)">
									Воспользоваться скидками
								</a>
							</li>
						</ul>
						<div class="tab-content">
							<div class="tab-pane fade"
							     :class="{'show active': (info.order.balls_mode == 'on')}">
								<p class="balls-result__box">
									<span class="mr-2 d-inline-flex">за заказ будет начислено:</span>
									<span class="theme-color text-nowrap">+{{info.order.balls_cashback}}</span>
								</p>
								<div class="balls-result__box d-flex">
									<div class="balls-result__pay-header mr-2">
										<div class="balls-result__pay-header__subheader1 fs18 text-nowrap">
											Оплатить заказ баллами
										</div>
										<div class="balls-result__pay-header__subheader2 fs12">(до 50%,
											максимум {{info.order.balls_max_can_pay}})
										</div>
									</div>
									<div class="balls-result__input mr-2">
										<input v-model="info.order.balls_to_pay"
										       v-if="info.order.payed_by_balls"
										       readonly="readonly"
										       :max="info.order.balls_max_can_pay_num"
										       class="form-control bg-white" type="number">
										<input v-model="info.order.balls_to_pay"
										       v-else
										       :max="info.order.balls_max_can_pay_num"
										       class="form-control bg-white" type="number">
									</div>
									<div class="balls-result__btn">
										<button v-if="info.order.payed_by_balls"
										        @click="handleBallsPayDecline()"
										        class="btn btn-outline-primary btn-outline-colored">
											Отменить
										</button>
										<button v-else
										        @click="handleBallsPay()"
										        class="btn btn-outline-primary btn-outline-colored">
											Оплатить
										</button>
									</div>
								</div>
								<p class="theme-color fs20">Внимание! Баллы отменяют все скидки и купоны при
									зачислении и списании. При оплате баллами баллы за заказ не
									начисляются.</p>
							</div>
							<div class="tab-pane fade"
							     :class="{'show active': (info.order.balls_mode == 'off')}">
								<p class="balls-result__box"><span
											class="mr-2">к заказу применится скидка:</span><span
											class="theme-color text-nowrap">–{{info.order.discount_price}}</span>
								</p>
								<p class="theme-color fs20">Внимание! Скидки отменяют баллы!</p>
							</div>
						</div>
					</div>
				</div>
			</div>
			
			<div class="cart__result-wrapper justify-content-between align-items-end">
				<div class="item__btn">
					<span @click="handlePrevStep(1)"
					      class="btn btn-alle btn-tocart btn-tocart_simple btn-tocart_big btn-tocart_cart-back">
						Вернуться
					</span>
				</div>
				<div class="cart__result">
					<div class="cart__result__line cart__result__line_delivery">
						<span class="cart__result__line__label">Доставка:</span>
						<span class="cart__result__line__value">{{info.order.delivery_price}}</span>
					</div>
					<div class="cart__result__line cart__result__line_full">
						<span class="cart__result__line__label">Итого:</span>
						<span class="cart__result__line__value">{{info.order.full_price}}</span>
					</div>
					<div class="cart__result__button item__btn">
						<span @click="handleFinalStep()"
						      class="btn btn-alle btn-tocart btn-tocart_simple btn-tocart_big">Далее
						</span>
					</div>
				</div>
			</div>
		</div>
	</div>
</div>

Вся работа с баллами со стороны фронтенда сводится к нескольким хэндлерам:

handleBallsModeOn() {
	this.info.order.balls_mode = 'on';
	this.getData('view');
},
handleBallsModeOff() {
	this.info.order.balls_mode = 'off';
	this.getData('view');
},
handleBallsPay() {
	if (parseInt(this.info.order.balls_to_pay) > parseInt(this.info.order.balls_max_can_pay_num)) {
		this.info.order.balls_to_pay = parseInt(this.info.order.balls_max_can_pay_num);
	}
	this.getData('view');
},
handleBallsPayDecline() {
	this.info.order.balls_to_pay = '';
	this.getData('view');
},

Шаг 3: Контактная информация.

Front end.

Разметка третьего шага — это обычная форма с инпутами. По сабмиту формы вызывается все тот же метод getData.

Результат

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

P.S. Наверное, ты единственный, кто дочитал! +100 к опыту, однозначно!

Здесь вы можете предложить тему для следующих статей
Заказать разработку