Сравнение товаров в интернет-магазине или webdevkin-shop возвращается

декабрь 19 , 2016
Предыдущая статья Следующая статья Демо Исходники

Да! После трехмесячного перерыва на страницы Webdevkin-а возвращаются интернет-магазины!

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

Кроме того, реализуем еще пару плюшек: позволим фильтровать по совпадающим или различающимся свойствам и сохранять в url набор сравниваемых товаров. Удивительно, но многие магазины этого не предлагают, хотя реализовывается достаточно просто и приносит дополнительный позитив.

Итак, понеслась.


Что будем делать и что получим в итоге?

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

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

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

Какие именно характеристики будем выводить?
Во-первых, те, которые обязательны для всех товаров (разумеется, в рамках нашего проекта). Это названия, цены, фото, бренды и рейтинги. Этот набор самых полезных свойств всегда есть у товара и грех его не использовать.
Во-вторых, набор дополнительных характеристик, которые будем задавать для каждого товара по отдельности. Например, модель процессора, объем памяти или размер экрана.

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

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

Посмотреть, как работает сравнение товаров здесь - shop.webdevkin.ru


Таблица свойств в базе данных

Для различных категорий товаров характерны совершенно разные свойства. Конечно, такие как наличие wi-fi, скоро будут присущи каждому утюгу. Но пока исходим из факта, что заранее набор свойств задать невозможно. Причем даже в пределах одной категории.

Я не хочу усложнять эту статью еще и заморочками со структурой базы, поэтому делаем максимально просто. Создадим таблицу goods_props всего с четырьмя полями:

id - автогенерируемый ключ
good_id - id товара
prop - свойство, например, "Процессор" или "Размер экрана"
value - значение свойства - например, "Intel Core i5"

То есть получается, в каждой строке таблицы хранится одно свойство одного товара. Если у конкретного товара 5 свойств, то это значит 5 строк таблицы.

Такая простейшая структура позволит нам создавать сколько угодно свойств для каких угодно товаров. При этом php-код для извлечения этих данных будет очень простой и не сильно отвлечет от основной задачи.

sql-код создания таблицы ниже. Тестовые данные для десятка товаров Вы найдете в исходниках, но также можете самовольно добавить и свои.

    create table goods_props (
        id int(10) unsigned not null auto_increment,
        good_id int(10) unsigned not null,
        prop varchar(255) not null,
        value varchar(255) not null,
        primary key (id)
    )
    engine = innodb
    auto_increment = 16
    avg_row_length = 1170
    character set utf8
    collate utf8_general_ci;

Добавляем кнопку "К сравнению" и библиотеку работы с куками

Самое простое - добавить кнопку. Для этого в файле catalog.html в underscore-шаблоне goods-template нужно добавить код новой кнопки после добавления в корзину

    

Весь шаблон получится такой

    

Дальше нам нужно решить, где хранить список товаров для сравнения. Корзину мы успешно держали в localStorage. Но я давно искал повод рассказать о маленькой jQuery-библиотеке для работы с куками и сейчас не удержался. Мы будем использовать $.cookie, благо разобраться с ней много времени не займет.

Можете нагуглить, где ее нарыть или просто скачать с webdevkin-a jquery.cookie.js и закинуть файлик в папку проекта js/vendor/. Кроме этого в catalog.html в конце страницы добавим этот скрипт после самого jquery

    

Теперь переходим к более интересному занятию - написанию js-кода для нового модуля.


Создаем модуль compare.js.

Добавим уже привычную заготовку для любого js-модуля нашего магазина. Файл compare.js

    'use strict';
    
    // Модуль сравнения товаров
    var compare = (function($) {
    
        // Инициализация модуля
        function init() {
            console.log('init compare');
        }
    
    
        // Экспортируем наружу
        return {
            init: init
        }
    
    })(jQuery);

А теперь подключим модуль на странице catalog.html и запустим compare.init(). А именно, в catalog.html предпоследней строкой, перед модулем main.js добавим

    

А в файле main.js нужно запустить инициализацию compare только на странице каталога с фильтрами. Найдем функцию init и код, который выполняется только для catalogDB. Добавим в конце вызов compare.init(), чтобы получилось так

    if (page === 'catalogDB') {
        catalogDB.init();
        cart.init(optionsCatalog);
        compare.init();
    }

Теперь если обновим страницу каталога, то увидим в консоли "init compare" - новый модуль завелся. Пора сделать в нем что-то полезное.


Добавление товара в куку для последующего сравнения

Нам нужно сделать так, чтобы при клике на кнопку "Добавить к сравнению" id соответствующего товара записывался в куку. Так как в куках можно хранить только строки, то для списка мы будем просто перечислять id через запятую. Назовем куку compared_goods. Это название будем использовать часто, поэтому вынесем эту строку в отдельный объект настроек. Также создадим объект ui, где будем хранить названия классов нужных элементов или сами элементы

Добавим следующий код в начало модуля compare.

    var ui = {
        $body: $('body'),
        elAddToCompare: '.js-add-to-compare'
    };

    var settings = {
        cookie: {
            goods: 'compared_goods'
        }
    };

Затем создаем уже традиционную функцию _bindHandlers, в которой устанавливаем обработчики событий, в нашем случае клик на кнопку "Добавить к сравнению". И не забываем добавить _bindHandlers в init.

    // Навешиваем события
    function _bindHandlers() {
        ui.$body.on('click', ui.elAddToCompare, _onClickAddToCompare);
    }

    // Инициализация модуля
    function init() {
        _bindHandlers();
    }

Осталось написать обработчик клика на кнопку _onClickAddToCompare, то есть добавление id товара в куку.

    // Добавление товара к сравнению
    function _onClickAddToCompare(e) {
        var $button = $(e.target),
            goodId = $button.attr('data-id'),
            comparedGoodsStr = $.cookie(settings.cookie.goods),
            comparedGoodsArr = comparedGoodsStr ? comparedGoodsStr.split(',') : [];

        // Проверяем, нет ли этого товара уже в куках
        if (comparedGoodsArr.indexOf(goodId) === -1) {
            // Добавляем новый товар в массив сравниваемых
            comparedGoodsArr.push(goodId);
            $.cookie(settings.cookie.goods, comparedGoodsArr.join(','), {expires: 365, path: '/'});
            alert('Товар добавлен к сравнению!');
        } else {
            alert('Этот товар уже есть в списке сравниваемых');
        }
    }

То есть первое - вытащили список товаров из куки в массив.
Второе - проверили, не был ли этот товар уже добавлен раньше.
И третье - если нет, то добавляем id в массив и перезаписываем куку. Если уже был, то ничего не делаем, но говорим об этом пользователю.

На заметку: метод $.cookie()
Записывает новое значение в куку этот код
$.cookie(cookieName, cookieValue, {expires: 365, path: '/'});
Первые 2 параметра - это название куки и значение.
expires означает, на какой срок (в днях), устанавливается кука - у нас на 1 год.
path - это параметр "путь". Будьте внимательны и всегда ставьте его в '/'. В противном случае каждая страница будет прописывать свой путь в куке. И получится, что кука с одним названием на разных страницах (index.html и catalog.html) будет хранить разное значение. Что в нашем случае непозволительно.

Но вернемся к статье. Итак, кука добавлена. Но остается одна проблема.


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

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

Что для этого нужно? Для начала знать категорию товара. Когда мы ее узнаем, то добавим также в куку эту категорию и перед добавлением к сравнению будем дополнительно проверять, совпадает ли категория выбираемого товара.

Звучит несколько пугающе, но сделать это очень просто. Вспомним, что в таблице goods в базе данных для каждого товара есть обязательное поле category_id - то, что нужно. Осталось только извлечь оное поле из базы, прокинуть его в data-атрибут в underscore-шаблоне и учесть при добавлении товара к сравнению. Это буквально десяток строк кода.

Сначала в scripts/catalog.php в функции getGoods в запросе select добавим выборку категории

    select
        ...
        g.category_id as category_id
    from
        ...

Затем в catalog.html чуть подправим шаблон goods-template, дописав у кнопки "Добавить к сравнению" атрибут

    data-category-id="<%= item.category_id %>"

И наконец, учтем новые условия в функции _onClickAddToCompare в compare.js, попутно добавив название новой куки для категории в объект settings. Приведу полный код функции, выделив новый код комментариями // НОВОЕ

    var settings = {
        cookie: {
            goods: 'compared_goods',
            // НОВОЕ
            category: 'compared_category'
        }
    };

    // Добавление товара к сравнению
    function _onClickAddToCompare(e) {
        var $button = $(e.target),
            goodId = $button.attr('data-id'),
            // НОВОЕ
            categoryId = $button.attr('data-category-id'),
            comparedGoodsStr = $.cookie(settings.cookie.goods),
            comparedGoodsArr = comparedGoodsStr ? comparedGoodsStr.split(',') : [],
            // НОВОЕ
            comparedCategoryId = $.cookie(settings.cookie.category);

        // НОВОЕ УСЛОВИЕ
        // Проверяем, совпадают ли категории товаров
        if (comparedCategoryId && categoryId !== comparedCategoryId) {
            alert('Не допускается сравнивать товары разных категорий');
            return false;
        }

        // Проверяем, нет ли этого товара уже в куках
        if (comparedGoodsArr.indexOf(goodId) === -1) {
            // Добавляем новый товар в массив сравниваемых
            comparedGoodsArr.push(goodId);
            $.cookie(settings.cookie.goods, comparedGoodsArr.join(','), {expires: 365, path: '/'});
            // НОВОЕ
            $.cookie(settings.cookie.category, categoryId, {expires: 365, path: '/'});
            alert('Товар добавлен к сравнению!');
        } else {
            alert('Этот товар уже есть в списке сравниваемых');
        }
    }

На этом с категориями закончили. Вы можете попробовать добавить товары, следя за ними в developer tools, и убедиться, что массив артикулов пополняется, но только в случае добавления товаров одной категории. На скриншоте ниже я добавил к сравнению товары с id 1, 4 и 5 категории 1

На заметку
Странные символы %2C - это запятые по таблицы ASCII.
$.cookie перед добавлением в куку кодирует строки функцией encodeURIComponent - она нам еще пригодится.

Итак, мы научились добавлять товары к сравнению. Но как показать это пользователям?


Показываем количество товаров, добавленных к сравнению

В самой первой статье про корзину мы использовали такой способ: рисовали количество товаров в корзине в меню, во вкладке "Корзина". Этот способ вполне подойдет и для сравнения. Дело за малым - реализовать это.

Добавим на все страницы нашего магазина новую вкладку "Сравнение товаров", а в загружаемые скрипты - библиотеку jquery.cookie.js и модуль compare.js. Отображать количество товаров во вкладке будет именно модуль compare.js, а именно функция updateCompareTab (о ней чуть позже)

Открываем все html-файлы: index, catalog, cart и order и добавляем в меню новую вкладку. Пусть она будет в центре, между каталогом с фильтрами и корзиной

    ...
    
  • Сравнение товаров
  • ...

    id="compare-tab" проставлен, потому что с этой вкладкой дальше будет работать js-код. В конце файлов index, cart и order добавим jquery.cookie.js и compare.js по аналогии со страницей catalog.html, где эти js-модули мы добавили чуть раньше.

    Тепреь займемся функцией updateCompareTab. Она всего лишь узнает, сколько товаров добавлено в куку и выводит их количество во вкладку #compare-tab. Расширим объект ui новым элементом и напишем такой код

        var ui = {
            ...
            $compareTab: $('#compare-tab')
        };
        
        ...
        
        // Обновление количества сравниваемых товаров во вкладке
        function updateCompareTab() {
            var comparedGoodsStr = $.cookie(settings.cookie.goods),
                comparedGoodsArr = comparedGoodsStr ? comparedGoodsStr.split(',') : [];
    
            // Обновляем метку с количеством товаров во вкладке compare
            ui.$compareTab.find('span').text(comparedGoodsArr.length || '');
        }
    

    Как видим, если в куке нет товаров, то мы пишем в метку не 0, а пустую строку - мне кажется, так симпатичнее :-)
    Осталось не забыть добавить вызов этой функции в _onClickAddToCompare - при добавлении товара. Это одна строка - ставится в самом конце, когда товар уже добавился в куку

        // Проверяем, нет ли этого товара уже в куках
        if (comparedGoodsArr.indexOf(goodId) === -1) {
            ...
            // НОВАЯ СТРОКА
            updateCompareTab();
            alert('Товар добавлен к сравнению!');
        } else {
            alert('Этот товар уже есть в списке сравниваемых');
        }
    

    Также нужно сделать эту функцию публичной в модуле compare

        // Экспортируем наружу
        return {
            updateCompareTab: updateCompareTab,
            init: init
        }
    

    А для чего? Для того, чтобы вызвать compare.updateCompareTab() на всех страницах магазина, а не только в каталоге. Добавим соответствующую строку в самый конец функции init модуля main.js.

        function init() {
            ...
            compare.updateCompareTab();
        }
    

    И теперь наконец можно обновить страницу, добавлять товары к сравнению и смотреть, как увеличивается число на вкладке "Сравнение"

    На заметку
    Пока мы не умеем удалять товары из куки. Поэтому для тестирования просто заходите в devTools на вкладку cookie, просто удаляйте обе куки и добавляйте товары заново.

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


    Формируем правильную ссылку

    Вы спросите: да какой еще один момент, какие ссылки?
    Давай уже рассказывай, как создать новую страницу самого сравнения?
    Как доставать товары из базы?
    Как отобразить таблицу с характеристиками, скриншот которой ты показал в начале поста?

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

    Вы вспоминаете, что у Вас есть шаристый друг-сисадмин, который разбирается не только в железе, а еще и в софте, пиве, политике и иногда в живописи. Нервы таких замечательных людей стоит беречь, а тут Вы ему звоните и предлагаете на каком-то сайте выбрать из 5 вариантов самый подходящий. Каким образом передать ему инфу о товарах? Это в нашем браузере мы извлечем список артикулов из куки, а у него этих кук нет.
    Скидывать ему URL или id товаров, пусть сам накидает, сравнит и вынесет вердикт?
    А если у Вас десяток вариантов?

    Давайте поступим так: сохраним в URL-е страницы сравнения товаров список артикулов. А при формировании таблицы с характеристиками будем вытаскивать артикулы не из куки, а из URL-а. Тогда нашему другу сисадмину достаточно будет отправить одну-единственную ссылку, которая откроет в его браузере правильный список товаров невзирая на наличие нужных кук.

    Технически это делается в несколько строк кода в функции updateCompareTab. Идея в том, чтобы на страницу сравнения вела не просто статичная ссылка compare.html, а динамически в нее добавлялся список артикулов, например, в get-параметр или хэш. Мы будем использовать хэш, с ним чуть проще работать. Также не забудем добавить в ссылку кроме артикулов еще и категорию.

    Обновим функцию updateCompareTab:

        // Обновление количества сравниваемых товаров во вкладке
        function updateCompareTab() {
            var comparedGoodsStr = $.cookie(settings.cookie.goods),
                comparedGoodsArr = comparedGoodsStr ? comparedGoodsStr.split(',') : [],
                comparedCategoryId = $.cookie(settings.cookie.category),
                compareHref = 'compare.html' + (comparedGoodsArr.length ? '#' + encodeURIComponent(comparedCategoryId + '|' + comparedGoodsStr) : '');
    
            // Обновляем метку с количеством товаров во вкладке compare
            ui.$compareTab.find('span').text(comparedGoodsArr.length || '');
    
            // Обновляем ссылку во вкладке compare
            ui.$compareTab.find('a').attr('href', compareHref);
        }
    

    Добавилось совсем немного. Последняя строка обновляет атрибут href тега a. Переменная compareHref формирует строку такого вида
    compare.html#1|3,4,8
    1 - это id категории, а 3,4,8 - список артикулов. Так как мы используем кодирование через encodeURIComponent, то в браузере ссылка будет выглядеть как
    compare.html#1%7C3%2C4%2C8
    где "%7C" - это "|", а "%2C" - ","

    Вот и все. Чуть забегая вперед, можно заценить, как это работает. Я добавил к сравнению 2 ноутбука с id = 1 и 2 - это макбуки Air и Pro. Перейдя по этой ссылке, Вы увидите именно их в сравнении. Все работает, как и задумали - как будто товары мы добавили руками.

    Теперь мы уходим со страницы каталога и всецело погружаемся в создание новой страницы compare.html


    Заготовка страницы compare.html

    Ничем не отличается от других страниц магазина. Скопируйте любую страницу, переименуйте в compare.html, проставьте у body атрибут data-page="compare". И замените содержимое body на

        
    
        
        
        
        
        
        
    

    Зайдем на эту страницу, посмотрим на гифку-крутилку, подметим, что во вкладке отобразилось количество товаров для сравнения и продолжим.


    Инициализация сравнения товаров - функция compare._initComparePage

    _initComparePage - это функция, выполняющаяся именно на странице сравнения. Посмотрим на ее код.

        // Инициализация страницы compare
        function _initComparePage() {
            var hashData = decodeURIComponent(location.hash).substr(1).split('|'),
                categoryId = hashData.length ? hashData[0] : 0,
                goods = hashData.length ? hashData[1] : [];
    
            if (!goods) {
                alert('Не выбраны товары для сравнения');
                return false;
            }
    
            // Записываем в куки значения из хэша
            $.cookie(settings.cookie.goods, goods, {expires: 365, path: '/'});
            $.cookie(settings.cookie.category, categoryId, {expires: 365, path: '/'});
        }
    

    Мы получаем в массив hashData из хэша 2 строки: категорию и список артикулов через запятую. Помним, что в хэше находится закодированная строка, поэтому применяем decodeURIComponent. Если выясняем, что товаров нет, то есть хэш пустой, то говорим об этом пользователю. И в конце записываем в куки полученные значения.

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

    Теперь, когда _initComparePage создана, нужно добавить ее вызов в compare.init

        // Инициализация модуля
        function init() {
            _bindHandlers();
            if (ui.$body.attr('data-page') === 'compare') {
                _initComparePage();
            }
        }
    

    А также в init главного модуля main.js

        ...
        if (page === 'compare') {
            cart.init(optionsCatalog);
            compare.init();
        }
        ...
    

    Теперь у нас при загрузке compare.html успешно парсится хэш и данные из него записываются в куки. Можем это проверить и переходить к запросу данных с сервера.


    Запрашиваем данные о товарах и характеристиках с сервера.

    Здесь код будет очень простой. Допишем ajax-запрос в _initComparePage, после установки кук

        // Установка кук
        ...
        // Запрашиваем данные с сервера
        $.ajax({
            url: 'scripts/compare.php',
            data: 'goods=' + encodeURIComponent(goods),
            type: 'GET',
            cache: false,
            dataType: 'json',
            error: _onAjaxError,
            success: function(response) {
                if (response.code === 'success') {
                    console.log(response);
                } else {
                    _onAjaxError(response);
                }
            }
        });
    

    Здесь видим, что данные мы требуем у скрипта scripts/compare.php, который вскоре создадим.
    В параметрах отправляем все ту же строку артикулов goods, предварительно ее закодировав.
    Запрещаем кэширование ответа.
    Ждем от сервера ответа в формате json.
    В случае успеха просто выводим в консоль ответ от сервера. Что с ответом делать дальше, разберем чуть позже.
    Любые ошибки обрабатываются функцией _onAjaxError. Кстати, напишем для нее заглушку

        // Ошибка получения данных
        function _onAjaxError(responce) {
            console.error('responce', responce);
            // Далее обработка ошибки, зависит от фантазии
        }
    

    Оставим пока в покое javascript и перейдем к серверной части


    PHP. Возвращаем данные о сравниваемых товарах

    Добавляем файл scripts/compare.php. Создаем заготовку файла, так же, как и в предыдущих статьях.

        // Объявляем нужные константы
        define('DB_HOST', 'localhost');
        define('DB_USER', 'root');
        define('DB_PASSWORD', 'root');
        define('DB_NAME', 'webdevkin');
        
        // Подключаемся к базе данных
        function connectDB() {
            $errorMessage = 'Невозможно подключиться к серверу базы данных';
            $conn = new mysqli(DB_HOST, DB_USER, DB_PASSWORD, DB_NAME);
            if (!$conn)
                throw new Exception($errorMessage);
            else {
                $query = $conn->query('set names utf8');
                if (!$query)
                    throw new Exception($errorMessage);
                else
                    return $conn;
            }
        }
        
        // Получение свойств товаров
        function getProps($goods, $conn) {
            // Вытаскиваем свойства из таблицы goods_props
        }
        
        // Получение всех данных для сравнения товаров
        function getData($goods, $conn) {
            $result = array(
                'props' => getProps($goods, $conn)
            );
        
            return $result;
        }
        
        
        try {
            // Подключаемся к базе данных
            $conn = connectDB();
        
            // Получаем товары
            $data = getData(urldecode($_GET['goods']), $conn);
        
            // Возвращаем клиенту успешный ответ
            echo json_encode(array(
                'code' => 'success',
                'data' => $data
            ));
        }
        catch (Exception $e) {
            // Возвращаем клиенту ответ с ошибкой
            echo json_encode(array(
                'code' => 'error',
                'message' => $e->getMessage()
            ));
        }
    

    Пока все знакомо по предыдущим урокам, самое интересное - в функции getProps

        // Получение свойств товаров
        function getProps($goods, $conn) {
            $query = "
                select
                    gp.good_id as good_id,
                    gp.prop as prop,
                    gp.value as value
                from
                    goods_props as gp
                where
                    gp.good_id in ($goods)
            ";
        
            $data = $conn->query($query);
            return $data->fetch_all(MYSQLI_ASSOC);
        }
    

    Интересное, но несложное. Мы просто извлекаем из таблицы goods_props все свойства для товаров с подходящими артикулами. Данные по характеристикам получили, но еще остаются цены, бренды, фото и рейтинги, которые хранятся в отдельной таблице goods. Напишем отдельную функцию для извлечения этих данных

        // Получение товаров
        function getGoods($goods, $conn) {
            $query = "
                select
                    g.id as good_id,
                    g.good as good,
                    b.brand as brand,
                    g.price as price,
                    g.rating as rating,
                    g.photo as photo
                from
                    goods as g,
                    brands as b
                where
                    g.id in ($goods) and
                    g.brand_id = b.id
            ";
        
            $data = $conn->query($query);
            return $data->fetch_all(MYSQLI_ASSOC);
        }
    

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

        // Получение всех данных для сравнения товаров
        function getData($goods, $conn) {
            $result = array(
                'goods' => getGoods($goods, $conn),
                'props' => getProps($goods, $conn)
            );
        
            return $result;
        }
    

    С серверным кодом все! Можно вернуться в браузер и убедиться, что сервера приходят нужные данные В дальнейшем будем писать код только на клиенте, чему несомненно порадуются любители фронтенда


    Разметка и стили для страницы сравнения

    Мы приступаем к верстке, но знаем, что в итоге будем отрисовывать данные через шаблоны underscore. Соответственно, есть 2 пути.
    Первый - сразу разбить страницу на логические блоки и писать шаблоны.
    Второй - сначала захардкодить всю верстку без раздумывания над отдельными смысловыми частями.

    Я предпочитаю второй способ.
    Во-первых, он позволяет сосредоточиться именно на разметке и стилях - над внешним видом страницы.
    Во-вторых, структура страницы довольно сложная, будет состоять из нескольких шаблонов. Легко ошибиться, пытаясь держать в голове кучу информации, от разметки и стилей до синтаксиса шаблонов и подставляемых в них данных.

    Поэтому будем делать все по очереди и сразу бахаем тонну html-кода.

        
    Показывать свойства:
    Ноутбук Apple MacBook Air
    70000 руб.
    Удалить из сравнения
    Ноутбук Apple MacBook Pro
    80000 руб.
    Удалить из сравнения
    Ноутбук Lenovo G5030
    40000 руб.
    Удалить из сравнения
    Бренд Apple Apple Lenovo
    Цена 70000 руб. 80000 руб. 40000 руб.
    Рейтинг 8 8 8
    Процессор Intel Core i5 Intel Core i7 Intel Core i3
    Объем памяти 4 Гб 8 Гб 8 Гб

    Вот так страшно (на первый взгляд) выглядит вся разметка для нашей страницы. А теперь нагоню еще жути - стили. Добавим в main.css такой блок

        /* Сравнение товаров */
        
        .compare-table th {
            text-align: center;
        }
        
        .compare-table th:first-child {
            text-align: left;
            vertical-align: middle;
        }
        
        .compare-table__good {
            font-size: 1.1em;
            color: steelblue;
        }
        
        .compare-table__photo {
            max-width: 150px;
        }
        
        .compare-table__price {
            color: red;
        }
        
        .compare-table__remove {
            color: steelblue;
            cursor: pointer;
            font-size: 0.8em;
            font-weight: normal;
            margin-top: 10px;
            text-decoration: underline;
        }
        
        .compare-table tbody tr td:first-child {
            font-weight: bold;
        }
        
        
        .compare-table[data-compare="all"] tbody tr {
             display: table-row;
        }
        
        .compare-table[data-compare="equal"] tbody tr:not(.-equal) {
            display: none;
        }
        
        .compare-table[data-compare="not-equal"] tbody tr.-equal {
            display: none;
        }
    

    Посмотрим, что у нас получилось и попытаемся осмыслить происходящее

    Сопоставив разметку с картинкой, видим, что не так уж все и страшно. Есть блок thead, первая строка таблицы. Он состоит из двух подблоков: фильтры и список товаров с названием, картинкой, ценой и кнопкой "Удалить из сравнения". Фильтры - это простой кусок html, а список товаров должен формироваться динамически на основе данных от сервера.

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

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

    В разметке и стилях можете заметить пару странных вещей. В таблице id="compare-table" стоит атрибут data-compare="all". У tr в строчке рейтинга есть класс -equal, рейтинги всех товаров равны (и это единственное совпадающее свойство).

    В стилях есть такие строчки

        .compare-table[data-compare="all"] tbody tr {
             display: table-row;
        }
        
        .compare-table[data-compare="equal"] tbody tr:not(.-equal) {
            display: none;
        }
        
        .compare-table[data-compare="not-equal"] tbody tr.-equal {
            display: none;
        }
    

    А в фильтрах радиокнопки имеют значения all, equal и not-equal. Вероятно, Вы уже просекли, что реализована фильтрация будет очень просто. При смене радиокнопки мы будем копировать ее значение в атрибут data-compare таблицы. Можете прямо сейчас в консоли вручную заменить этот атрибут и посмотреть, как будет меняться содержимое таблицы. Главное в этом деле правильно установить класс -equal для одинаковых свойств товаров.

    Впрочем, не будем слишком много времени уделять верстке. Все равно скорее всего она у Вас будет своя. А лучше перейдем к, пожалуй, самой сложной части сравнения товаров - это обработка на клиенте данных, полученных с сервера, и отрисовка их в шаблонах underscore.


    Избавляемся от хардкодной верстки и пишем общий метод рендера

    Сейчас у нас в compare.html зашита верстка всей страницы. При получении ответа от сервера мы выводим данные в консоль браузера. Пора переходить к разбиению верстки на шаблоны и рендеру разметки на основе реальных данных.

    Для начала напишем основу функции рендера - _renderCompareTable

        // Рендер таблицы сравнения
        function _renderCompareTable(response) {
            console.log(response);
        }
    

    А в ajax-вызове заменим вывод в консоль на вызов метода _renderCompareTable

        ...
        success: function(response) {
            if (response.code === 'success') {
                _renderCompareTable(response);
            } else {
                _onAjaxError(response);
            }
        }
        ...
    

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

        

    Думаю, по структуре понятно. Мы выделили 3 блока, на каждый из которых заведем отдельный шаблон и подставим туда нужные данные. Полученный html будем ставить в места, отмеченные комментариями.

    Давайте сразу внесем таблицу в объект ui и создадим новый пустой объект tpl - для шаблонов.

        var ui = {
            $body: $('body'),
            elAddToCompare: '.js-add-to-compare',
            $compareTab: $('#compare-tab'),
            // НОВЫЙ ЭЛЕМЕНТ
            $compareTable: $('#compare-table')
        };
    
        // НОВЫЙ ОБЪЕКТ
        var tpl = {};
    

    Начнем рендерить блоки по порядку - сначала фильтры.


    Рендер блока с фильтрами

    Так как этот блок единственный, который не зависит от данных, то можно было его просто оставить как есть. И влепить в таблицу целиковым блоком. Но кусок разметки этот довольно большой и с повторяющимися радиокнопками. Неужели мы не сможем сделать код компактнее и приятнее на вид?

    Идея в том, чтобы данные для радиокнопок - заголовок, значение и выбор по умолчанию - вынести в массив. А этот массив прогнать через underscore-шаблон. Начинаем с javascript-a

        // Рендер таблицы сравнения
        function _renderCompareTable(response) {
            var filters = [{
                    value: 'all',
                    title: 'Все',
                    checked: true
                },{
                    value: 'equal',
                    title: 'Совпадающие'
                },{
                    value: 'not-equal',
                    title: 'Различающиеся'
                }];
    
            // Рендерим фильтры
            ui.$compareTable.find('thead tr').html(tpl.filters({
                buttons: filters
            }));
        }
    

    Добавим поле filters в объект tpl

        var tpl = {
            filters: _.template($('#compare-filters-template').html() || '')
        };
    

    А в compare.html напишем сам underscore-шаблон.

        
        
    

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

    В общем, с самым простым разобрались, переходим к заголовку таблицы - списку товаров


    Рендер блока с товарами

    Действуем по той же схеме: в _renderCompareTable получаем нужные данные, прогоняем их через шаблон, добавленный в tpl, и пишем сам шаблон. Данные для этого списка к нам приходят с сервера в массиве data.goods. Этот массив мы и передадим без особых затей в шаблон. Выглядит так

        var tpl = {
            filters: _.template($('#compare-filters-template').html() || ''),
            header: _.template($('#compare-header-template').html() || '')
        };
        
        ...
        
        // Рендер таблицы сравнения
        function _renderCompareTable(response) {
            var filters = [...];
    
            var goods = response.data.goods;
    
            // Рендерим фильтры
            ...
    
            // Рендерим товары в шапке таблицы
            ui.$compareTable.find('thead tr').append(tpl.header({
                goods: goods
            }));
        }
    

    В js-коде все еще проще, чем с фильтрами, так как нам не нужно задавать данные руками, мы берем их с сервера из response.data.goods. Теперь шаблон

        
        
    

    Здесь тоже нехитро - пробегаемся по списку товаров и для каждого рисуем одну ячейку. Верстку мы сделали заранее, поэтому остается только заменить хардкодные данные на конструкции underscore.

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


    Как поступить с характеристиками?

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

    Напомню, так выглядит массив response.data.goods. Вся информация для одного товара хранится в одном объекте. В качестве характеристик нас интересуют 3 поля: бренд, цена и рейтинг

        [{
            good_id: 1,
            good: 'Ноутбук Apple MacBook Air',
            brand: 'Apple',
            photo: 'apple_macbook_air.jpg',
            price: 60000,
            rating: 8
        },{
            ...
        }, {
            ...
        }]
    

    А вот массив props, где хранятся характеристики из таблицы goods_props, выглядит по-другому

        [{
            good_id: 1,
            prop: 'Процессор',
            value: 'Intel Core i5'
        },{
            good_id: 1,
            prop: 'Объем памяти',
            value: '4 Гб'
        },{
            good_id: 1,
            prop: 'Размер экрана',
            value: '13 дюймов'
        },{
            good_id: 2,
            prop: 'Процессор',
            value: 'Intel Core i7'
        }, {
            ...
        }]
    

    Отличие в том, что в каждом элементе массива хранится одно свойство одного товара.

    Теперь посмотрим на верстку и подумаем, какие данные мы хотим подставить в шаблон. Каждая строка таблицы представляет собой название свойства плюс список значений оных свойств для каждого товара. И не забываем, что в каждой строке должна быть информация, а не совпадают ли для этого свойства характеристики всех товаров. То есть удобная структура для нас будет такова:

        [{
            // Первая строка - свойство бренд, совпадает
            prop: 'Бренд',
            values: [{
                goodId: 1,
                value: 'Apple'
            },{
                goodId: 2,
                value: 'Apple'
            }],
            equal: true
        }, {
            // Вторая строка - цена, различается
            prop: 'Цена',
            values: [{
                goodId: 1,
                value: 60000
            },{
                goodId: 2,
                value: 70000
            }]
        },{
            // Следующее свойство и так далее ...
        }]
    

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

    Конечно, можно сделать 2 отдельных шаблона, сначала вывести свойства из goods, потом из props. Но во-первых, это не очень хорошо, потому что свойство цена и процессор по сути ничем не отличаются. Это мы знаем, что они берутся из разных таблиц, но фактически это одна и та же сущность. А во-вторых, даже если пойти по более простому пути, то данные все равно нужно преобразовывать.

    Мы поступим по-другому. Идея в том, чтобы написать 2 независимые функции, которые преобразуют массивы goods и props в нужный нам вид. После этого мы объединим полученные 2 массива с одинаковой структурой и загоним их в один-единственный шаблон. Здесь придется потрудиться и внимательно обработать данные на javascript с помощью underscore, но результат того стоит.

    Где обрабатывать данные: на сервере или клиенте?
    Если Вы работаете один, то по трудоемкости разницы нет: или сложная логика на сервере, а клиент получает готовые данные, или сервер отдает данные как есть, а всю обработку выполняет клиент.
    Но в пользу клиента говорят такие вещи: экономия ресурсов сервера и разделение логики на клиенте и сервере. Сервер не должен знать, как будет отображать данные браузер. К тому же в любой момент вид отображения может поменяться. И раз уж клиент занимается отрисовкой данных, то пусть он же выполняет преобразования, как ему нужно.
    Ну и конечно, личные предпочтения: с javascript мне нравится работать больше, чем php :-)
    А как считаете Вы?

    Внимание, дальше будет не самый простой js-код, разбавленный underscore-функциями.


    Преобразовываем данные из response.data.goods

    Посмотрим на такую функцию _getBaseProps

        // Получение массива основных свойств из response.data.goods
        function _getBaseProps(goods) {
            // Конфиг для базовых свойств
            var baseProps = [{
                key: 'brand',
                prop: 'Бренд'
            }, {
                key: 'price',
                prop: 'Цена'
            }, {
                key: 'rating',
                prop: 'Рейтинг'
            }];
    
            var valuesWithIds, values, equal;
    
            // Возвращаем свойства со списком значений
            return _.map(baseProps, function(item) {
    
                // Массив объектов из id и значений для конкретного свойства
                valuesWithIds = _.map(goods, function(good) {
                    return {
                        goodId: good.good_id,
                        value: good[item.key]
                    }
                });
    
                // Массив значений конкретного свойства
                values = _.pluck(valuesWithIds, 'value');
    
                // Одинаковые ли значения во всех товарах
                equal = _.uniq(values).length === 1;
    
                // Возвращаем объект с набором данных
                return {
                    prop: item.prop,
                    values: valuesWithIds,
                    equal: equal
                }
            });
        }
    

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

    Поле key в конфиге означает название поля в goods, а prop - название человеческое, для отображения в таблице. Далее мы объявляем ряд вспомогательных переменных и проводим манипуляции, на выходе из которых получится аккурат тот самый массив, который мы посчитали удобным в предыдущем разделе.

    Проделаем то же самое с массивом response.data.props


    Преобразовываем данные из response.data.props

    Напишем функцию _getAdditionalProps

        // Получение массива дополнительных свойств из response.data.props
        function _getAdditionalProps(props) {
            var valuesWithIds, values, equal;
            return _.chain(props)
                .groupBy('prop')
                .map(function(valuesArray, key) {
    
                    // Массив объектов из id и значений для конкретного свойства
                    valuesWithIds = _.map(valuesArray, function(item) {
                        return {
                            goodId: item.good_id,
                            value: item.value
                        }
                    });
    
                    // Массив значений конкретного свойства
                    values = _.pluck(valuesWithIds, 'value');
    
                    // Одинаковые ли значения во всех товарах
                    equal = (values.length > 1) && (_.uniq(values).length === 1);
    
                    return {
                        prop: key,
                        values: valuesWithIds,
                        equal: equal
                    }
                })
                .value();
        }
    

    Идея та же - на вход список свойств с бекенда, на выходе - нужный нам массив. В центре - озорство и магия.

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


    Общий js-код и шаблон для характеристик товаров

    Добавим в объект tpl последний шаблон и добьем функцию _renderCompareTable

        var tpl = {
            filters: _.template($('#compare-filters-template').html() || ''),
            header: _.template($('#compare-header-template').html() || ''),
            props: _.template($('#compare-props-template').html() || '')
        };
    
        ...
        
        // Рендер таблицы сравнения
        function _renderCompareTable(response) {
            var filters = [...];
    
            var goods = response.data.goods;
    
            var allProps = _.union(
                _getBaseProps(goods),
                _getAdditionalProps(response.data.props)
            );
    
            // Рендерим фильтры
            ...
    
            // Рендерим товары в шапке таблицы
            ...
    
            // Рендерим свойства товаров в таблице
            ui.$compareTable.find('tbody').append(tpl.props({
                goods: _.pluck(goods, 'good_id'),
                props: allProps
            }));
        }
    

    Все готово, осталось добавить шаблон в compare.html

        
        
    

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

    Дело за малым: настроить фильтры и удаление товаров из сравнения. После того, что мы сделали, это будет как раз чихнуть. Давайте же закодим.


    Фильтрация по совпадающим свойствам

    Вспомним, что всю работу для этого мы фактически сделали. Часть взял на себя css, а часть разметка. Смотрим, что осталось.

        // Пункт первый: новый элемент в объект ui
        var ui = {
            ...
            elCompareFilters: '.js-compare-filter',
            ...
        }
        
        // Пункт второй: обработчик клика по радиокнопкам в _bindHandlers
        function _bindHandlers() {
            ui.$body.on('click', ui.elAddToCompare, _onClickAddToCompare);
            // НОВАЯ СТРОКА
            ui.$body.on('click', ui.elCompareFilters, _onClickCompareFilters);
        }
        
        // Пункт третий: реализация смены фильтра _onClickCompareFilters
        
        // Смена фильтра
        function _onClickCompareFilters(e) {
            ui.$compareTable.attr('data-compare', e.target.value);
        }
    

    И все! Я предупреждал, что оставшаяся часть довольно проста. Давайте разберемся с последним пунктом - удалением товаров.


    Удаление товаров из сравнения

    Действуем точно так же, как и с фильтрами - в 3 шага

        // Пункт первый: новый элемент в объект ui
        var ui = {
            ...
            elCompareRemove: '.js-compare-remove',
            ...
        }
        
        // Пункт второй: обработчик клика по кнопкам "Удалить из сравнения" в _bindHandlers
        function _bindHandlers() {
            ui.$body.on('click', ui.elAddToCompare, _onClickAddToCompare);
            ui.$body.on('click', ui.elCompareFilters, _onClickCompareFilters);
            // НОВАЯ СТРОКА
            ui.$body.on('click', ui.elCompareRemove, _onClickCompareRemove);
        }
        
        // Пункт третий: реализация удаления _onClickCompareRemove
        
        // Удаление товара из списка сравниваемых
        function _onClickCompareRemove(e) {
            var id = $(e.target).attr('data-id'),
                goods = $.cookie(settings.cookie.goods).split(','),
                categoryId = $.cookie(settings.cookie.category),
                newGoods = _.without(goods, id),
                newGoodsStr = newGoods.join(',');
    
            // Удаляем куки, если исключили все товары
            if (!newGoodsStr) {
                $.removeCookie(settings.cookie.goods, {path: '/'});
                $.removeCookie(settings.cookie.category, {path: '/'});
            }
    
            // Меняем хэш и перезагружаем страницу
            document.location.hash = newGoodsStr ? encodeURIComponent(categoryId + '|' + newGoodsStr) : '';
            document.location.reload();
        }
    

    Поздравляю!
    Это последние строчки, которые мы написали для реализации сравнения товаров в интернет-магазине.

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

    P.S. Под конец повествования у меня проснулась совесть, которая не позволила для простой процедуры написать еще 3 десятка строк кода. Мы и так понаписали этого кода больше, чем хотелось бы. Пора, наконец, и отдохнуть, а заодно и полюбоваться результатами нашей работы. Что Вы можете сделать, переписав этот код самостоятельно или забрав исходники интернет-магазина


    Итоги и напутствия

    Сначала еще раз самое важное - исходники.
    А потом уже все остальное...

    Написана очередная статья про интернет-магазины. Функционал которого разрастается на глазах. По сравнению с первой статьей про корзину кода стало больше раза эдак в 4.

    Большинство из статей я даже не могу воткнуть в формат демо-странички и подумываю, что пора уже придумать пафосное название для нашего магазина и выделить ради него поддомен.

    UPDATED: сделано. Завел отдельный поддомен для демо интернет-магазина

    На этом все. Если у Вас есть какие-то предложения, пожелания или критика, оставляйте все это в комментариях!

    Все об интернет-магазинах

    Предыдущая статья Следующая статья Демо Исходники
    Заходите в группу в контакте - https://vk.com/webdevkin
    Анонсы статей, обсуждения интернет-магазинов, vue, фронтенда, php, гита.
    Истории из жизни айти и обсуждение кода.
    Как Вам статья? Оцените!