2013-09-06

Корзина и форма заказа на AngularJS и Twitter Bootstrap

Начнем с предыстории

Давно я что-то не писал в блог. А ведь столько всего произошло и продолжает происходить в мире информационных технологий.
Давно уже поверхностно знакомился с фрейворком для верстки Twitter Bootstrap и JavaScript-фреймворком для разработки веб-приложений AngularJS.
Очень интересные штуки и я просто мечтал выкроить время, чтобы хоть где-то на практике их использовать. И вот это день настал - ко мне обратился бывший сокурсник с просьбой разработать сайт интернет-магазин. Сайт ему нужен был полностью, от "а до я". Дизайнер из меня никакой, поэтому сразу в голову пришла идея - использовать готовое решение Twitter Bootstrap, на мой взгляд его компоненты выглядят достаточно приятно и современно, к тому же накануне вышла обновленная 3-я версия с серьезными переработками и улучшениями. Идея попробовать AngularJS пришла уже по ходу разработки функционала корзины.
Да, еще обратил внимание, что у меня блогозаписи практически не проиллюстрированы и поэтому воспринимаются хуже. Исправляюсь - теперь по возможности будет больше картинок. Неужто, красота Twitter Bootstrap дала мне понять насколько скучны мои посты с одним лишь текстом и вкраплениями кусков кода? :-)

Twitter Bootstrap

Его я использовал на всем сайте. Хотелось бы, конечно, еще красивых рисованных элементов дизайна, но рисовать я не умею, так что пришлось довольствоваться глифами. Зато на сайте акцент будет сделан на красивые фото товаров.
Twitter Bootstrap - это набор готовых стилей для таких элементов как кнопки, метки, панели, списки, элементы форм и многие другие. Фреймворк также включает в себя готовые стили для типографики и небольшой, но полезный набора JavaScript-плагинов.
Важно, что имеется поддержка адаптивного дизайна - сайт сам подстраивается под разрешение устройства. Сетка включает в себя 12 колонок и с помощью классов (префиксов) можно управлять их размерами и положениями для 4-х видов устройств:
  • .col-xs-: экстра-маленькие устройства, телефоны (<768px)
  • .col-sm-: маленькие устройства, планшеты (≥768px)
  • .col-md-: средние устройства, PC (≥992px)
  • .col-lg-: большие устройства, PC (≥1200px)
Как минимум, верстка заключается в написании HTML-кода и прописывании тегам готовых классов и data-атрибутов для JavaScript-плагинов. В итоге для всей верстки на Twitter Bootstrap мне практически не пришлось писать свои стили.

AngularJS

Мощный JavaScript-фреймворк, удобен для разработки одностраничных веб-приложений.
С ним я еще не очень хорошо разобрался, ибо применил его только для реализации небольшого функционала корзины.

Корзина с заказами и форма заявки

Основной функционал сайта заключен в разделе "Корзина".
Рассмотрим функционал сначала в картинках, а потом и в коде.
Список товаров в заказе:
 Товар пометили как удаляемый:
 Форма с обязательным полем "Телефон":
 Форма с одним верно заполненным полем и активным:
 Форма с неверно заполненным полем:
 Все обязательные поля заполнены, все поля заполнены верно:
Далее приведен реализующий весь этот функционал JavaScript- и HTML-код с комментариями.
В JavaScript объявляются контроллеры и модели:
function CartForm($scope) {
 // корзина в json
 $scope.items = [{"id":34,"quantity":2,"info":"\u043e\u0434\u0438\u043d \u0441\u0435\u0440\u044b\u0439, \u0434\u0440\u0443\u0433\u043e\u0439 \u0431\u0435\u043b\u044b\u0439","name":"\u0422\u0435\u0441\u0442\u043e\u0432\u044b\u0439 \u0442\u043e\u0432\u0430\u0440\u0447\u0438\u043a","price":"123","detail_url":"\/katalog\/kpb\/byaz\/byaz-15-sp\/34\/","icon_url":"\/bitrix\/templates\/tb\/images\/1.png","picture_url":null,"nav":[{"name":"\u041a\u041f\u0411","url":"\/katalog\/kpb\/"},{"name":"\u0411\u044f\u0437\u044c","url":"\/katalog\/kpb\/byaz\/"},{"name":"1,5 \u0441\u043f.","url":"\/katalog\/kpb\/byaz\/byaz-15-sp\/"}]}];
 // -1 товар
 $scope.minus = function(index) {
  if ($scope.items[index].quantity > 0) {
   $scope.cartform.$setDirty();
   $scope.items[index].quantity--;
  }
 };
 // +1 товар
$scope.plus = function(index) { $scope.cartform.$setDirty(); $scope.items[index].quantity++; };
 // удаление товара (сброс кол-ва в 0)
$scope.removeItem = function(index, id) { $scope.cartform.$setDirty(); $scope.items[index].quantity = 0; };
 // подсчет итоговой суммы
$scope.total = function() { var total = 0; angular.forEach($scope.items, function(item) { total += item.quantity * item.price; }); return total; };
 // проверка корзины на пустоту
$scope.has_items = function() { return $scope.items.length > 0; };
 // содержимое корзины
$scope.items_cart = function() { var items = []; angular.forEach($scope.items, function(item) { items.push({ id: item.id, quantity: item.quantity, info: item.info }); }); return items; };
 // флаг, указывающий на процесс обновления корзины на сервере
$scope.cartproc = false;
 // процесс обновления корзины на сервере
$scope.save = function() { $scope.cartproc = true; var items = $scope.items_cart(); $.post('/cart/cart.php', {'cart': items}, function(data, textStatus, jqXHR) { var ids = jQuery.map(data, function(el) { return el.id; }); $scope.items = jQuery.grep($scope.items, function(el) { return jQuery.inArray(el.id, ids) >= 0; }); $scope.cartproc = false;
   // помечаем форму как не тронутую
$scope.cartform.$setPristine(); $('#cart-size').html(data.length);
   // применяем изменения модели для обновления представления
$scope.$apply(); }, 'json'); };
 // флаг, указывающий на процесс отправки заявки на сервере
$scope.orderproc = false;
 // процесс обновления корзины на сервере
$scope.send = function() { $scope.orderproc = true; var items = $scope.items_cart(); var order = $scope.order; $.post('/cart/cart.php', {'cart': items, 'order': order}, function(data, textStatus, jqXHR) { var ids = jQuery.map(data, function(el) { return el.id; }); $scope.items = jQuery.grep($scope.items, function(el) { return jQuery.inArray(el.id, ids) >= 0; }); $scope.orderproc = false;
   // помечаем форму как не тронутую
$scope.cartform.$setPristine(); $('#cart-size').html(data.length);
   // плагин Twitter Bootstrap для модальных окон
$('#order-alert').modal();
   // применяем изменения модели для обновления представления
$scope.$apply(); }, 'json'); };
 // модель заявки
$scope.order = { name: '', email: '', phone: '', address: '', comments: '' }; }

HTML:
<!-- приложение AngularJS -->
<div class="container" ng-app>
  <!-- контроллер CartForm -->
  <div ng:controller="CartForm">
    <div class="row">
      <form novalidate id="cartform" name="cartform">
        <!-- table-responsive: на маленьких разрешениях у корзины появится полоса прокрутки и вся страница не будет растянута -->
        <div class="table-responsive">
          <table class="table">
            <thead>
              <tr>
                <th>Товар</th>
                <th>Фото</th>
                <th>Количество и комментарии</th>
                <th>Цена</th>
                <th>Сумма</th>
                <th></th>
              </tr>
            </thead>
            <tbody>
              <--
              1) вывод товаров (item) из корзины (items)
              2) условие: если заказываемое кол-во товара равно 0, то он считается удаляемым и помечается полупрозрачной строкой
              -->
              <tr ng:repeat="item in items" ng-class="{opacity50: item.quantity < 1}">
                <td>
                  <!-- вывод название товара, ссылки на его страницу и "хлебных крошек" разделов до его страницы -->
                  <p><a href="{{item.detail_url}}">{{item.name}}</a></p>
                  <ol class="breadcrumb">
                    <li ng:repeat="sect in item.nav">
                      <a href="{{sect.url}}">{{sect.name}}</a>
                    </li>
                  </ol>
                </td>
                <td class="carousel-extended">
                  <!-- ng-show: картинка будет показана только если она задана в модели товара -->
                  <a href="{{item.picture_url}}" ng-show="item.picture_url" class="thumbnail image-detail">
                    <img src="{{item.icon_url}}" width="50" height="50" alt="{{item.name}}">
                  </a>
                </td>
                <td>
                  <div class="row">
                    <div class="form-group col-xs-8" ng-class="{'has-error': cartform.quantity[{{item.id}}].$invalid}">
                      <-- для элемента формы задаются правила валидации -->
                      <input type="text" name="quantity[{{item.id}}]" ng:model="item.quantity" ng:required ng:pattern="/^\d+$/" min="0" class="form-control input-sm">
                    </div>
                    <!-- ng-click: уменьшение/увеличение заказываемого кол-ва -->
                    <button ng-click="minus($index)" class="btn btn-default input-sm glyphicon glyphicon-minus"></button>
                    <button ng-click="plus($index)" class="btn btn-default input-sm glyphicon glyphicon-plus"></button>
                  </div>
                  <textarea ng:model="item.info" class="form-control input-sm"></textarea>
                </td>
                <td>{{item.price}}</td>
                <td>{{item.quantity * item.price}}</td>
                <td><a href data-toggle="tooltip" title="пометить для удаления" data-placement="left" class="pointer helptip glyphicon glyphicon-remove text-danger" ng:click="removeItem($index, item.id)"></a></td>
              </tr>
            </tbody>
            <tfoot>
              <tr>
                <td colspan="2">
                  <-- ng-show="cartproc": полоса прогресса будет показана пока установлен флаг cartproc (пока выполняется ajax-запрос) -->
                  <div ng-show="cartproc" class="progress progress-striped active">
                  <div class="progress-bar" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="width: 100%"></div>
                </div>
              </td>
              <-- ng-switch: в зависимости от результатов валидации будет показана либо активная, либо не активная кнопка -->
              <td ng-switch="cartform.$dirty && cartform.$valid && !cartproc && has_items()">
                <button ng-switch-when="true" ng-click="save()" class="btn btn-success btn-xs pull-right">Сохранить изменения</button>
                <button ng-switch-default class="btn btn-success btn-xs pull-right" disabled>Сохранить изменения</button>
              </td>
              <td>Итого:</td>
                <!-- вывод итоговой суммы с применением форматирования -->
                <td><strong><p class="text-primary">{{total() | currency:""}}</p></strong></td>
                <td></td>
              </tr>
            </tfoot>
          </table>
        </div>
      </form>
    </div>
    <!-- ng-switch: если в корзине пусто, то вместо формы заказа будет выведено соответствующее сообщение -->
    <div ng-switch="has_items()">
      <div ng-switch-when="true">
        <div class="row">
          <div class="col-md-offset-2 col-md-8">
            <h3>Оформление заказа:</h3>
          </div>
          <div class="col-md-2"></div>
        </div>
        <div class="row">
          <div class="col-md-offset-2 col-md-8">
            <form novalidate class="form-horizontal" id="orderform" name="orderform">
              <div class="form-group" ng-class="{'has-success': order.name}">
                <label for="order_name" class="control-label col-lg-3">Имя:</label>
                  <div class="col-lg-9">
                    <input type="text" class="form-control" id="order_name" name="order_name" ng-model="order.name">
                  </div>
                </div>
                <!--
                в зависимости от результатов валидации элементу формы будет применен класс из Twitter Bootstrap:
                has-error при ошибке заполнения
                has-success при правильно заполненном поле
                -->
                <div class="form-group" ng-class="{'has-error': orderform.order_email.$invalid, 'has-success': !orderform.order_email.$invalid && order.email}">
                <label for="order_email" class="control-label col-lg-3">Эл. почта:</label>
                <div class="col-lg-9">
                  <input type="email" class="form-control" id="order_email" name="order_email" ng-model="order.email">
                </div>
              </div>
              <div class="form-group" ng-class="{'has-error': orderform.order_phone.$invalid, 'has-success': !orderform.order_phone.$invalid}">
                <label for="order_phone" class="control-label col-lg-3">Телефон:</label>
                <div class="col-lg-9">
                  <input type="text" class="form-control" id="order_phone" name="order_phone" ng-model="order.phone" required>
                </div>
              </div>
              <div class="form-group" ng-class="{'has-success': order.address}">
                <label for="order_address" class="control-label col-lg-3">Адрес:</label>
                <div class="col-lg-9">
                  <input type="text" class="form-control" id="order_address" name="order_address" ng-model="order.address">
                </div>
              </div>
              <div class="form-group" ng-class="{'has-success': order.comments}">
                <label for="order_comments" class="control-label col-lg-3">Комментарии к заказу:</label>
                <div class="col-lg-9">
                  <textarea type="text" class="form-control" id="order_comments" name="order_comments" ng-model="order.comments"></textarea>
                </div>
              </div>
            </form>
          </div>
          <div class="col-md-2"></div>
        </div>
        <div class="row">
          <div class="col-md-offset-2 col-md-2" ng-switch="cartform.$valid && orderform.$valid && !orderproc && has_items()">
            <button ng-switch-when="true" ng-click="send()" class="btn btn-success">Отправить</button>
            <button ng-switch-default class="btn btn-success" disabled>Отправить</button>
          </div>
          <div class="col-md-6">
            <div ng-show="orderproc" class="progress progress-striped active">
              <div class="progress-bar" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="width: 100%"></div>
            </div>
          </div>
            <div class="col-md-2"></div>
        </div>
      </div>
      <div ng-switch-default>
        <div class="alert alert-warning">Корзина пуста</div>
      </div>
    </div>
  </div>
</div>