Развиваем тему интернет-магазина. Реализуем оформление заказа на клиенте и сервере

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

Оформление заказа в интернет-магазинеВследствие большой популярности поста про корзину для интернет-магазина на javascipt и интереса публики к постройке магазинов в целом решил запилить напрашивающееся продолжение означенной статьи. Сейчас мы рассмотрим следующий этап: сбор данных о покупателе и его заказе и отправка их на сервер.
Что я подразумеваю под оформлением заказа? Как владельцу магазина мне бы хотелось предоставить на своем сайте страничку, где покупатель после накидывания товаров в корзину введет свои контактные данные, нажмет одну кнопку "Отправить заказ", и эти данные каким-то образом будут мне известны, сохранены в надежном месте, и я получу уведомление о новом заказе, дабы не пропустить это радостное событие и максимально быстро обработать этот заказ.
Чем это грозит программисту? Сперва нужно создать эту страничку, нарисовать на ней форму с нужными полями, вроде имени, телефона и адреса доставки. Затем написать немного javascript-кода, который будет, в первую очередь, собирать данные и отправлять их на сервер, а во вторую очередь, заниматься всякими прикольными штуками, например, валидацией введенных данных, обработкой ошибок от сервера, показом сообщений от этих ошибок и прочее. После этого создать в mysql несколько табличек, в которую нужно сохранить полную информацию о заказе, и написать серверный код, который и будет эту инфу обрабатывать. И последняя важная часть - это отправка писем менеджеру магазину и самому покупателю.
Все, что я перечислил, это минимальный, но вполне достаточный набор для функционирования большинства несложных интернет-магазинов.
И теперь подробнее о том, как это реализовать...


Как это будет реализовано?

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

По технической части:

  • 1. фронт, как и в прошлый раз, будет в отдельном js-модуле.
  • 2. подключен jQuery для манипуляции с dom и отправки ajax-запроса.
  • 3. underscore.js - для некоторых операций с данными.
  • 4. bootstrap - чтобы не лепить свою верстку, а быстро запилить неброский, но адекватный интерфейс.
  • 5. таблицы созданы для mysql, весь sql-код приведен.
  • 6. серверная часть написана на php, для взаимодествия с базой используется mysqli.
  • 7. отправка писем происходит нативными средствами php, но с небольшими хитростями.

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

Обратите внимание: отправка заказа работает и с этой демо-страницы!

Если хотите посмотреть, как это работает, вводите свой email, жмите "Отправить заказ" и на него придет письмо с данными о заказе. А в консоли в ответе от сервера Вы увидите sql-запросы, которые выполняются при добавлении заказа.


Каркас страницы и код формы

Файл order.html, лежащий рядом с index.html (каталогом) и cart.html (корзиной) ничем особенно по структуре не отличается. Подключены те же стили и js-модули, что и на предыдущих страницах. Различие, конечно же, в содержании.

На странице заказа мы хотим видеть 2 блока.
Первый - это сообщение о том, сколько товаров в нашей корзине имеется и на какую сумму. Вероятно, не помешал бы вывод всех товаров, но это можете прикрутить сами. Готовый код уже есть в модуле корзины.
Второй блок - форма с 5 полями: имя, email, телефон, адрес доставки и сообщение/примечание. Это минимальный набор сведений, которые мы хотим знать о клиентах магазина. Поля имя и email у нас будут обязательными.

Начнем с кода блока информационного сообщения

    

Вот так он и выглядит. А текст для блока будет формироваться по нашей старой привычке через шаблон underscore. Код шаблона:

    

Переменные count и summa мы вытащим из модуля корзины. Это будет после, уже при инициализации js-модуля заказа. В шаблоне присутствует нехитрая логика: проверяем, если корзина не пустая, то выдаем информацию о ней. Если пустая, то выводим соответствующее сообщение с просьбой добавить в нее что-нибудь.

Второй блок: разметка формы заказа.

    

Это стандартная bootstrap-овская разметка формы. Здесь перечислены 5 полей, 3 input-а - для имени, email и телефона, 2 textarea - для адреса и сообщения. Из интересного - это 2 контейнера над кнопкой Отправить заказ.

    
    

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


Подключение js-модуля заказа в основном файле приложения main.js

Все по аналогии с подключением модуля корзины и каталога. В файле main.js дописывается немного кода для инициализации заказа. Нас интересуют 2 участка:

    // дополнительные опции для модуля заказа
    optionsOrder = _.extend({
        renderCartOnInit: false,
        renderMenuCartOnInit: true
    }, options);
    
    // инициализация js-кода для страницы заказа в секции app.init()
    if (page === 'order') {
        order.init();
        cart.init(optionsOrder);
    }

Напоминаю: на странице заказа нам нужно иметь доступ к корзине. Не нужно ее рендерить, но нужно нарисовать количество заказов в метке у пункта главного меню. Поэтому и используется такой набор опций - renderCartOnInit: false, renderMenuCartOnInit: true. Точно такой же, как для страницы каталога. order.init() запускает весь код, относящийся к заказу, а cart.init(optionsOrder) отрисовывает метку в главном меню, чтобы мы всегда видели, что в корзине у нас что-то есть (или нет)


Модуль отправки заказа - order.js

Напишем сам модуль order.js. Он построен по тому же принципу, что и cart.js, и catalog.js. Это отдельный js-модуль на основе замыкания, который инкапсулирует в себе какие-то приватные методы, а наружу отдает метод init(). Все остальное внешнему миру не нужно.
Можете сразу посмотреть на весь код, что из этого получится - ссылка на order.js

Теперь подробнее. Что мы хотим видеть в этом модуле?

  • 1. Элементы dom, закешированные в переменные для быстрого к ним обращения.
  • 2. Стандартная функция init() - единственная публичная, доступная извне.
  • 3. Метод _bindHandlers - куда уж без него, в нем навешиваются события на клики кнопок, сабмит форм и прочее.
  • 4. Метод валидации формы, который проверяет правильность заполненных полей.
  • 5. Сабмит формы - непосредственно отправка заказа на сервер.
  • 6. Ряд функций, выполняющихся в случае успеха или неуспеха ajax-запроса.
  • 7. И парочка служебных функций, наподобие получения данных из корзины и закрытия сообщений об ошибках валидации/успешном заказе
Задачу на небольшие подзадачи разбили, теперь будет легче. Рассмотрим их детальнее.


Кэшируем элементы dom в переменные

Здесь самое простое: вытаскиваем с помощью jQuery все нужные нам элементы и сохраняем в переменные. Делается это только один раз, все переменные хранятся в одном месте, в объекте ui, и доступны из любой функции модуля. Но недоступны извне, потому как ui находится внутри замыкания.

    var ui = {
        $orderForm: $('#order-form'),
        $messageCart: $('#order-message'),
        $orderBtn: $('#order-btn'),
        $alertValidation: $('#alert-validation'),
        $alertOrderDone: $('#alert-order-done'),
        $orderMessageTemplate: $('#order-message-template')
    };

Инициализация order.js, рендерим информационное сообщение, проверяем, не пуста ли корзина и навешиваем события

Тут тоже несложно

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

    // Рендерим сообщение о количестве товаров и общей сумме
    function _renderMessage() {
        var template = _.template(ui.$orderMessageTemplate.html()),
            data;
        cart.update();
        data = {
            count: cart.getCountAll(),
            summa: cart.getSumma()
        };
        ui.$messageCart.html(template(data));
    }

    // В случае пустой корзины отключаем кнопку Отправки заказа
    function _checkCart() {
        if (cart.getCountAll() === 0) {
            ui.$orderBtn.attr('disabled', 'disabled');
        }
    }

    // Навешивам события
    function _bindHandlers() {
        ui.$orderForm.on('click', '.js-close-alert', _closeAlert);
        ui.$orderForm.on('submit', _onSubmitForm);
    }

init() просто вызывает 3 метода: рендер сообщения, проверку корзины и навешивание событий. В рендере мы обновляем корзину (cart - глобальная переменная, модуль корзины), вытаскиваем из нее нужные данные, количество и сумму, и через _.template ставим нужный текст в нужное место. _checkCart отключает кнопку отправки заказа, если в корзине ничего нет. А в _bindHandlers вообще всего лишь клик на закрытие выпадающих сообщений и сабмит формы. Клик на закрытие выглядит так:

    // Закрытие alert-а
    function _closeAlert(e) {
        $(e.target).parent().addClass('hidden');
    }

Через e.target мы обращаемся к элементу, на котором произошел клик (кнопка закрытия), оборачиваем его в объект jQuery для удобства, ищем родителя (блок с сообщением) и добавляем ему класс hidden, тем самым скрывая оный блок.


Валидация формы, функция _validate

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

    // Валидация формы
    function _validate() {
        var formData = ui.$orderForm.serializeArray(),
            name = _.find(formData, {name: 'name'}).value,
            email = _.find(formData, {name: 'email'}).value,
            isValid = (name !== '') && (email !== '');
        return isValid;
    }

Здесь мы с помощью serializeArray получаем массив из значений формы в переменную formData. Затем достаем имя и email и проверяем, не пустые ли они. Если нет, то валидацию считаем успешной.

На самом деле здесь можно было бы обойтись одной простой строчкой

    return ($('#input-name').val() !== '') && ($('#input-email').val() !== '');

Дело вкуса, как по мне, так работать с данными в таких случаях всегда приятнее, чем напрямую с dom-элементами.


Сабмит формы, отправка ajax-запроса на сервер

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

Сначала код, а потом разберем его.

    // Оформляем заказ
    function _onSubmitForm(e) {
        var isValid,
            formData,
            cartData,
            orderData;
        e.preventDefault();
        ui.$alertValidation.addClass('hidden');
        isValid = _validate();
        if (!isValid) {
            ui.$alertValidation.removeClass('hidden');
            return false;
        }
        formData = ui.$orderForm.serialize();
        cartData = _getCartData();
        orderData = formData + '&cart=' + JSON.stringify(cartData);
        ui.$orderBtn.attr('disabled', 'disabled').text('Идет отправка заказа...');
        $.ajax({
            url: 'scripts/order.php',
            data: orderData,
            type: 'POST',
            cache: false,
            dataType: 'json',
            error: _orderError,
            success: function(responce) {
                if (responce.code === 'success') {
                    _orderSuccess(responce);
                } else {
                    _orderError(responce);
                }
            },
            complete: _orderComplete
        });
    }

Что здесь происходит? Нам нужны 4 переменные, одна определяет, валидна ли форма, 3 остальных - данные для отправки. Зачем столько, увидим дальше. Первым делом следует e.preventDefault() - мы не хотим, чтобы при сабмите наша страница перезагружалась, а именно это и произойдет без preventDefault-а. Потом скрываем блок с ошибкой валидации, он мог быть виден, если пользователь жмет на Отправить уже не в первый раз. В переменную isValid сохраняем результат валидации, и если она не пройдена, показываем блок с ошибкой и выходим из функции - return false. В случае успешной валидации начинаем собирать данные. В formData попадают данные с самой формы - это те самые 5 полей, которые забиваются вручную. Данные для корзины, напоминаю, хранятся у нас в localStorage и доступны через cart, поэтому для данных корзины - отдельная переменная cartData. На _getCartData() пока забейте, дальше станет понятно, что она делает. Пока просто примите, что в cartData массив товаров в виде [{"id":5,"name":"name","price":1000,"count":1}, {...}]" И orderData собирает все данные, которые отправляются на сервер. Затем отключаем кнопку Отправить заказ, меняем ее текст, чтобы показать пользователю, что идет какой-то процесс и запускаем отправку запроса ajax.

Здесь все параметры стандартны. Код обработки заказа мы будем хранить в файле order.php из папки scripts, которая находится в корне проекта. Путь к файлу указываем в параметре url. data - это данные, отправляемые на сервер. type: 'POST' - метод отправки. cache: false - на всякий случай отказываемся от кэширования. dataType: 'json' - мы уверены, что сервер всегда возвращает нам json-строку и проследим за этим в php-коде. Пока что обязательным условием считаем, что сервер всегда возвращает в json поле code, которое указывает, как завершилась отправка заказа. В случае code === 'success' считаем, что все прошло успешно. В параметре success пишем функцию, которая определяет, что делаем в случае успешного или неуспешного ответа от сервера - запускаем соответствующую функцию _orderSuccess или _orderError. В complete пишем функцию, которая вызывается по завершении ajax-запроса, независимо от того, с каким успехом он завершился. В нашем случае _orderSuccess и _orderComplete выглядят так:

    // Успешная отправка
    function _orderSuccess(responce) {
        console.info('responce', responce);
        ui.$orderForm[0].reset();
        ui.$alertOrderDone.removeClass('hidden');
    }

    // Отправка завершилась
    function _orderComplete() {
        ui.$orderBtn.removeAttr('disabled').text('Отправить заказ');
    }

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


Обработка ошибок

Обратите внимание на параметр error: _orderError. Здесь дублируется указание функции _orderError, но вызывается она здесь и в success по разным причинам. В success она может вызваться в случае ошибки сервера, которую мы сами отловили и можем вернуть адекватный ответ в браузер. Например, мы не смогли подключиться к базе данных или отправить письмо. В этом случае мы отлавливаем исключение на сервере и отправляем в браузер код ошибки code='error'. То есть это тот случай, когда мы контролируем эту ошибку и заранее пишем код для ее обработки (на сервере).

А вот в error эта же функция сработает, когда, например, недоступен ресурс /scripts/order.php или возникли неполадки с сетью (пропал интернет). В этом случае тоже следует как-то давать знать пользователю, что что-то пошло не так.

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

    // Ошибка отправки
    function _orderError(responce) {
        console.error('responce', responce);
        // Далее обработка ошибки, зависит от фантазии
    }

В консоль просто выбрасывается ответ от сервера, а Вы сами его обрабатываете, как хотите. Как вариант, можете подготовить одну дополнительную плашку с сообщением об ошибке и показывать ее в случае любой ошибки, независимо от кода. А можете отлавливать код ошибки по ее названию или в дополнение к параметру code в json отдавать еще и какой-нибудь errorMessage, который будет формироваться на сервере в зависимости от ситуации. Второй вариант ничуть не сложнее, просто придется писать больше кода, но зато Ваши пользователи получат более точную информацию. Например, "Сбой подключения к базе данных", "Не отправилось письмо" или "Сетевая ошибка, проверьте, есть ли интернет". Как сформировать такие варианты сообщений, я покажу ниже, когда будем разбирать код order.php. В общем, вариантов масса, в качестве учебного примера рассматривать их и усложнять код не хочется.


Вспоминаем про _getCartData

    // Подготовка данных корзины к отправке заказа
    function _getCartData() {
        var cartData = cart.getData();
        _.each(cart.getData(), function(item) {
            item.name = encodeURIComponent(item.name);
        });
        return cartData;
    }

Ничего особенного здесь не происходит, но описываю отдельно. Мы храним строки в localStorage без лишней обработки. Пока нам это не мешало, но при отправке данных на сервер строки нужно закодировать. Служебные символы вроде амперсанда & и ряда других должны быть заменены. Например, тот же амперсанд кодируется как %26. Вот мы и проделываем с массивом товаров такую нехитрую манипуляцию и с помощью encodeURIComponent кодируем названия товаров. Главное, не забыть на сервере раскодировать их обратно.


Начинаем работать с серверной частью. Создаем таблицы в базе данных.

Давайте вспомним, какие данные у нас есть и что мы хотим хранить в БД.
Имя клиента, его email, телефон, адрес доставки и сообщение/примечание.
И данные из корзины: массив товаров из объектов с такими полями: id товара, название, количество, цена.
Эти данные мы разобьем на 3 таблицы: clients, orders и details. В таблицу клиентов мы будем держать id клиента (первичный ключ, автоинкремент), его имя, email, телефон и дата добавления клиента. В таблицу orders попадут id заказа (первичный ключ, автоинкремент), id клиента, чтобы знать, какой заказ кому принадлежит, адрес, сообщение и дата создания заказа. В таблице содержимое заказов будет составной первичный ключ (id заказа + id товара), название товара, его количество и цена. Даты добавления клиента и заказа - это разные даты, потому как клиент может заказывать у нас что-то повторно.

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

Дальше привожу sql-код для создания всех трех таблиц. Каждое поле разъяснять не буду, там достаточно понятно. Назовем нашу БД, например, webdevkin.

Клиенты

    use webdevkin;
    create table clients (
        id int(10) unsigned not null auto_increment,
        name varchar(255) default null,
        email varchar(50) default null,
        phone varchar(50) default null,
        dt_added timestamp default current_timestamp on update current_timestamp,
        primary key (id)
    )
    engine = innodb
    auto_increment = 2
    avg_row_length = 16384
    character set utf8
    collate utf8_general_ci;

Заказы

    use webdevkin;
    create table orders (
        id int(10) unsigned not null auto_increment,
        client_id int(10) unsigned not null,
        address varchar(255) default null,
        message varchar(255) default null,
        dt_added timestamp default current_timestamp on update current_timestamp,
        primary key (id)
    )
    engine = innodb
    auto_increment = 2
    avg_row_length = 16384
    character set utf8
    collate utf8_general_ci;

Детали заказа

    use webdevkin;
    create table details (
        order_id int(11) not null,
        good_id int(11) not null,
        good varchar(255) not null,
        price int(11) not null,
        count int(11) not null,
        primary key (order_id, good_id)
    )
    engine = innodb
    avg_row_length = 8192
    character set utf8
    collate utf8_general_ci;    

Лирическое отступление по поводу структуры БД

В качестве примера такая структура базы нас вполне устраивает. Но в реальном мире стоит обратить внимание на следующие вещи:

  • 1. Поле orders.client_id должно быть внешним ключом к client.id
  • 2. Поле details.order_id должно быть внешним ключом к orders.id
  • 3. Категорически нужна таблица goods, из которой берутся названия товаров
  • 4. Вытекает из предыдущего пункта: поле details.good вообще не нужно
  • 5. А вот details.price нужно, ведь мы хотим хранить цену, по которой продали конкретный товар, несмотря на то, что впоследствии цена могла измениться
  • 6. Поле details.good_id должно быть внешним ключом к goods.id
  • 7. orders.address и orders.message, возможно, стоит сделать типом text
  • 8. Лучше настроить правила каскадного удаления, а именно
  • 9. При удалении записи в clients удаляются все связанные с ней записи в orders.
  • 10. При удалении записи в orders удаляются все связанные с ней записи в details.
Эти рекомендации особенно не повлияют при создании несложного магазина, но помогут, когда наша база разрастется до нескольких десятков связанных таблиц. И тогда нормализация и каскадные правила облегчат нам жизнь и дальнейшую поддержку магазина. Если у Вас есть еще какие-то предложения/рекомендации, с удовольствием прочитаю их в коментах.


Что нужно сделать на бэкенде?

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

  • 1. Набор констант, которые будут хранить доступы для подключения к базе данных, email администратора магазина, название магазина и прочее.
  • 2. Подключение к базе данных
  • 3. Получение данных из массива POST и необходимые преобразования над ними
  • 4. Добавление клиента (в бд)
  • 5. Добавление заказа (в бд)
  • 6. Добавление деталей заказа (в бд)
  • 7. Отправка писем на почту администратора/менежера магазина и самому клиенту
  • 8. Обработка ошибок и возвращение их клиенту в браузер
  • 9. Сбор всего этого добра в кучу.
Итак, задача разбита на небольшие части, так будет работать с ней горадо легче. Мы не будем заморачиваться с развесистыми ООП-конструкциями, а сделаем максимально просто в функциональном стиле.
Поехали.


Создание файла order.php и набора констант

И опять в начале пути мы делаем самое простое. Создаем в корне проекта папку scripts, в нее положим файл order.php. В котором пишем:

    // Объявляем нужные константы
    define('DB_HOST', 'localhost');
    define('DB_USER', 'root');
    define('DB_PASSWORD', 'root');
    define('DB_NAME', 'webdevkin');
    
    define('EMAIL_ADMIN', 'webdevkin@gmail.com');
    define('EMAIL_FROM_NAME', 'Интернет-магазин Webdevkin');
    define('SITE', 'webdevkin.ru');

Первые 4 константы - параметры подключения к БД: хост, юзер/пароль и название базы. EMAIL_ADMIN - это email администратора, на который будут отправляться письма о заказах. EMAIL_FROM_NAME - эта строка задает то название, которое Ваш клиент хотел бы видеть в письме в поле От кого. SITE - понятно, название сайта


Подключаемся к базе данных

    // Подключаемся к базе данных
    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;
        }
    }

Здесь мы подключаемся стандартно через mysqli (убедитесь, что у Вас включен/установлен соответствующий модуль). Возвращаем объект mysqli, который у нас будет использоваться практически в каждой функции скрипта. Здесь же мы используем выброс исключений в случае, если что-то пошло не так. В конце файла мы увидим, как эти исключения перехватываются и возвращаются клиенту в браузер. Принудительная установка кодировки в utf8 - это старинный хак для поддержки кириллицы, без которого до сих пор не знаю, все ли будет хорошо работать на разных версиях mysql и разных настройках БД.


Получение данных из массива $_POST. Не все так просто.

Казалось бы, в чем проблема: $_POST['email'] вернет нам email клиента. Да, но этим привычным нехитрым способом мы увеличиваем риски таких нехороших штук, как sql-инъекции. Не хочу приводить примеры, что это такое и как воспользоваться :-) Тем более, я далеко не спец по безопасности, погуглить на эту тему Вы можете и сами. Но предпочитаю уменьшать риски взлома и атак там, где это не требует особых усилий. И сейчас покажу, как это делается. Для этого нам понадобится функция mysqli_real_escape_string

    // Получаем данные из массива POST и экранируем их
    function getParam($param, $conn, $default = '') {
        return (isset($_POST[$param])) ? mysqli_real_escape_string($conn, $_POST[$param]) : $default;
    }
    
    // Подготавливаем данные
    function getData($conn) {
        return array(
            'name' => getParam('name', $conn, 'noname'),
            'email' => getParam('email', $conn, 'unknown email'),
            'phone' => getParam('phone', $conn),
            'address' => getParam('address', $conn),
            'message' => getParam('message', $conn),
            'cart' => isset($_POST['cart']) ? stripslashes($_POST['cart']) : '[]'
        );
    }

Функция getParam извлекает из массива $_POST нужное значение по названию параметра. $conn - это который $conn = connectDB() передается в getParam только потому, что она нужна в главной нашей функции mysqli_real_escape_string. И $default - значение по умолчанию, если в $_POST нужного параметра не оказалось

И вторая функция getData извлекает все нужные нам данные в ассоциативный массив, чтобы с ним было удобнее работать. Здесь уже учтено экранирование и значения по умолчанию. На выходе мы получаем готовые данные, которые можем безопасно использовать в sql-запросах. С массивом cart немного другая история - мы прогоняем строку cart через stripslashes, чтобы декодировать строку. Напоминаю, названия товаров были закодированы на фронте с помощью encodeURIComponent. А экранировать названия товаров мы будем позже, при добавлении в таблицу details.

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


Добавление клиента

А сейчас начинается самая обыденная часть серверного кода - добавление данных в наши таблицы. Вытаскиваем нужные данные из массива $data - он получен в результате работы getData. Формируем строку sql-запроса и выполняем его. В случае с добавлением в таблицы клиентов и заказов еще возвращаем id вставленной записи, так как он понадобится нам для следующих запросов.

    // Добавление клиента
    function addClient($data, $conn) {
        $query = sprintf(
            "insert into clients (`name`, `email`, `phone`) values ('%s', '%s', '%s')",
            $data['name'],
            $data['email'],
            $data['phone']
        );
        $conn->query($query);
        return $conn->insert_id;
    }

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


Добавление в таблицу orders

Здесь все по аналогии с предыдущим кодом.

    // Добавление заказа
    function addOrder($data, $conn) {
        $query = sprintf(
            "insert into orders (`client_id`, `address`, `message`) values (%d, '%s', '%s')",
            $data['client_id'],
            $data['address'],
            $data['message']
        );
        $conn->query($query);
        return $conn->insert_id;
    }

Добавление в таблицу details

    // Добавление деталей заказа
    function addDetails($data, $conn) {
        $cart = json_decode($data['cart'], true);
        $orderId = $data['order_id'];
        $values = array();
        foreach($cart as $cartItem) {
            $value = sprintf(
                "(%d, %d, '%s', %d, %d)",
                $orderId,
                $cartItem['id'],
                mysqli_real_escape_string($conn, $cartItem['name']),
                $cartItem['price'],
                $cartItem['count']
            );
            array_push($values, $value);
        }
        $query = sprintf(
            "insert into details (`order_id`, `good_id`, `good`, `price`, `count`) values %s",
            implode(',', $values)
        );
        $conn->query($query);
    }

Здесь кода чуть больше. Сначала мы переводим строку cart в ассоциативный массив. Потом, чтобы не добавлять каждый товар в таблицу по отдельности, воспользуемся множественным запросом insert. Про множественный insert и update можете прочитать в этой статье. Здесь же мы в массиве $values формируем набор из строк вида (1, 'Товар 1', 1, 1000) - то есть (id товара, название, количество, цена). При этом экранируем названия товаров через mysqli_real_escape_string. И в конце формируем итоговый запрос на вставку и выполняем его.

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


Отправка писем

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

    // Отправка письма
    function sendEmail($options) {
        $headers = "Content-type: text/html; charset=utf-8 \r\n";
        $headers .= 'From: =?utf-8?B?' . base64_encode($options['fromName']) . '?=<' . $options['fromEmail'] . '>';
        return mail($options['toEmail'], $options['subject'], $options['body'], $headers);
    }

Далее рассмотрим функцию отправки письма уже для нашего конкретного случая. Вот так это выглядит

    // Отправка письма с заказом
    function sendEmailOrder($data) {
        $data['title'] = 'Заказ с сайта ' . SITE;
        $cart = json_decode($data['cart'], true);
        ob_start();
        include('tpl/email_order.php');
        $body = ob_get_contents();
        ob_end_clean();
        $sendClient = sendEmail(array(
            'subject' => 'Ваш заказ с сайта ' . SITE,
            'fromName' => EMAIL_FROM_NAME,
            'fromEmail' => EMAIL_ADMIN,
            'toEmail' => $data['email'],
            'body' => $body
        ));
        if (!$sendClient) {
            throw new Exception('Ошибка отправки почты на email клиента');
        }
        $sendAdmin = sendEmail(array(
            'subject' => 'Новый заказ с сайта ' . SITE,
            'fromName' => EMAIL_FROM_NAME,
            'fromEmail' => EMAIL_ADMIN,
            'toEmail' => EMAIL_ADMIN,
            'body' => $body
        ));
        if (!$sendAdmin) {
            throw new Exception('Ошибка отправки почты на email админа');
        }
    }

Здесь мы устанавливаем заголовок письма, в переменную $body записываем текст письма и делаем собственно отправку. $sendClient и $sendAdmin хранят соответственно результаты отправки писем клиенту и админу. В случае ошибок выбрасываются исключения, как и в случае с подключением к БД. Различаются 2 вызова sendEmail набором параметров: в первом случае в качестве получателя письма указан email клиента из массива $data['email'], во втором - константа EMAIL_ADMIN. Так же задается разная тема письма. По желанию можно для админа и клиента подготовить разные версии писем, но я предпочитаю готовить версию для клиента, а админу отправлять ее копию. В конце концов, админу важно получить само письмо, его интересуют данные, а для покупателя нужно постараться и подготовить приятное для него сообщение.

UPDATED: выяснилось уже после публикации. Похоже, не все хостеры поддерживают возможность задавать руками email отправителя при отправке писем. Мой beget все-таки отправляет свой ящик noreply.beget-бла-бла-бла@com. Например, majordomo на момент написания статьи это позволяет. Поэтому, заработает ли такая возможность у Вас, зависит от Вашего хостинга.


Шаблон письма

Вы наверняка заметили простецкое выражение $body = 'Текст письма' и подумали "неужели это все, что он хочет нам рассказать?!!". Конечно, нет. Просто тема подготовки шаблона письма стоит особняком от самой отправки, и я выношу это в отдельный раздел.

Идея такова.

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

Итак, мы хотим вместо убогой строки $body = 'Текст письма' написать много html-разметки со вставленными в нее данными из массива $data. И здесь мы близки к тому, чтобы совершить большое зло - слить в одну кучу логику, данные и разметку. Условно говоря, MVC от нас все дальше и дальше. На фронте мы решили это посредством underscore, нам помог _.template(tpl, data). Нам нужно придумать что-то похожее и здесь, но не хочется тащить в наш небольшой проект ради одной функции шаблонизаторы типа smarty.

Давайте вспомним о существовании в php двух замечательных функций: include и ob_get_contents. Первой, включением в файл содержимого другого файла, наверняка пользуетесь постоянно. Вторая не так распростанена и согласно документации она "возвращает содержимое буфера вывода без его очистки". Чтобы пояснить, что это за фигня, проще сначала взглянуть на код.

    $cart = json_decode($data['cart'], true);
    ob_start();
    include('tpl/email_order.php');
    $body = ob_get_contents();
    ob_end_clean();

Функция ob_start включает буферизацию вывода. По-русски говоря, весь вывод echo и прочих операторов не отдается, а сохраняется во внутреннем буфере. К которому мы можем получить доступ через ob_get_contents. Сохраним значение буфера в переменную $body. А ob_end_clean останавливает это безобразие, и вывод дальше работает как обычно. А что же мы будем сохранять в $body? Это и есть наш html-шаблон для письма, который мы подключим из внешнего файла, и тем самым не будем захламлять логику. include('tpl/email_order.php') - этот файл и содержит код шаблона.

    
Спасибо, что выбрали наш магазин!
Ваш заказ №
Общие сведения:
Имя:
Email:
Телефон:
Адрес:
Сообщение:
Состав заказа:
id товара Название Цена Количество

Парсер дико пожрал php-шный код, посмотреть его по-нормальному Вы можете, скачав исходники, но идея видна и здесь. Мы пишем обычную разметку для html-страницы и встраиваем в нужные места код вида echo $data['name']. Здесь приведена минимальная разметка через inline-стили, но Вы можете создать какой угодно красивый вид письма. Для генерации таблицы с составом заказа приведу для наглядности отдельно. Этот код вставляется между тегами tbody. Но часть тегов сожрана и здесь, хоть скриншоты вставляй :-(

    foreach($cart as $cartItem) {
        echo sprintf(
            "%s%s%s%s",
            $cartItem['id'],
            $cartItem['name'],
            $cartItem['price'],
            $cartItem['count']
        );
    }

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


Последний этап, собираем все вместе

    try {
        // Подключаемся к базе данных
        $conn = connectDB();
        // Получаем данные из массива POST
        $data = getData($conn);
    
        // Добавляем запись в таблицу Клиенты
        $clientId = addClient($data, $conn);
        $data['client_id'] = $clientId;
    
        // Добавляем запись в таблицу Заказы
        $orderId = addOrder($data, $conn);
        $data['order_id'] = $orderId;
    
        // Добавляем товары в таблицу Детали
        addDetails($data, $conn);
    
        // Отправляем письма
        sendEmailOrder($data);
    
        // Возвращаем клиенту успешный ответ
        echo json_encode(array(
            'code' => 'success'
        ));
    }
    catch (Exception $e) {
        // Возвращаем клиенту ответ с ошибкой
        echo json_encode(array(
            'code' => 'error',
            'message' => $e->getMessage()
        ));
    }

Все функции мы уже разобрали, сейчас просто вызываем их последовательно. Результаты добавления клиента и заказа сохраняем в переменные и добавляем их в общий массив $data, потому что они используются в последующих функциях. В конце мы отправляем в браузер клиента json-объект с единственным полем code: 'success'. В случае перехвата исключения в блоке catch мы можем к коду code: 'error' отправлять еще и сообщение об ошибке. Также можно добавить в ответ какие угодно параметры, тип ошибки, рекомендации к исправлению и все это обрабатывать на фронте. Но как говорили ранее, это отдельная большая тема, мы рассмотрели только ее основы.


Выводы

Мы написали минимальный функционал отправки заказа, который тем не менее позволит нам запустить вполне себе рабочий интернет-магазин. Главные функции: получение данных с клиента и отправку на сервер он выполняет. Экспериментируйте, правьте код под свои нужды, предлагайте свои идеи!


Про исходники

Предыдущую версию магазина Вы могли скачать в любую локальную папку и приложение прекрасно работало. Фронт он и есть фронт, так и должно быть. В случае с исходниками не все так хорошо.
Во-первых, не забудьте создать базу данных в mysql и все 3 таблицы.
А во-вторых, если Вы работаете локально, то очень возможно, что smtp-сервер у Вас не установлен. Следовательно попытка отправки писем будет всегда выбрасывать исключение. Вы можете увидеть эту ошибку в консоли с текстом "Ошибка отправки почты на email клиента". Это правильное поведение скрипта, но для разработки нам не подходит. Поэтому просто закомментируйте код отправки писем на время локальной разработки. А письма тестируйте на хостинге, где ничего не нужно настраивать, все уже настроено до нас. Строка, отвечающая за отправку писем, sendEmailOrder($data) - в самом конце order.php.

В остальном, если Вы правильно прописали настройки к подключению БД и mysqli установлен, то все будет работать, в таблицы будут добавляться заказы.

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

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