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

Процент брошенных корзин с 2006 по 2017


Процент брошенных корзин с 2006 по 2017
Источник

Процент брошенных корзин на первый квартал 2018 года в разрезе индустрии:
Процент брошенных корзин на первый квартал 2018 года в разрезе индустрии
Источник

При этом, несмотря на общедоступную статистику, большинство интернет-магазинов не пользуются доступными возможностями и не подключают брошенную корзину. Недавнее «домашнее» исследование от EmailSoldiers наглядно показывает, что бОльшая часть магазинов вообще не замо��ачивается об этом.

Текущая статистика по подключенным брошенным корзинам


Исследование EmailSoldiers
Источник

При этом все (и мы тоже не святые) нагоняют трафик, докручивают рекламу и креативы, но даже не пытаются вернуть человека, который сорвался в последний момент.

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

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

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

Конверсия для брошенной корзины по данным RetailRocket


RetailRocket конверсия брошенной корзины
Источник

И вот мы с камрадом Артемом Александровым начали внедрение корзины с двух сторон.

Техническая реализация


ТЗ на интеграцию


Кратко описываем суть задачи.

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

Выдаем все необходимые материалы.

Ключ API: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX-usXX

Где взять ключ?

Где взять ключ?

Даем ссылку на документацию

ID листа, к которому подключаем Store: XXXXXXXXXX

Где взять ID листа?

Где взять ID листа?

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

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

Верстка шаблона


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

Создание автоматизации для брошенной корзины в Mailchimp

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

Письма на выбор для брошенной корзины Mailchimp
  1. Брошенная корзина с динамическими товарами
  2. Брошенная корзина с продуктовыми рекомендациями (нужно настраивать отдельно)
  3. Брошенная корзина без товаров (просто текстовое письмо)

В лучших традициях, если у вас есть время, можно заверстать корзину самостоятельно.

Динамические товары без стилей выглядят примерно так (забудем про отступы, они некрасивенько здесь отображаются):

<table>
<tbody> 
*|ABANDONED_CART:[$total=3]|*
<table>
<tbody> 
<tr>
<td>
<a href="*|CART:URL|*" title="*|PRODUCT:TITLE|*" target="_blank">
<img src="*|PRODUCT:IMAGE_URL|*">
</a>
</td>
<td>
*|PRODUCT:TITLE|* — *|PRODUCT:PRICE|*
</td>
</tr>
</tbody>
</table>
*|END:ABANDONED_CART|*
</tbody>
</table>
*|END:ABANDONED_CART|*
</tbody>
</table>

Казалось бы, если изменить цифру в переменной *|ABANDONED_CART:[$total=3]|*, то в письме будет отображаться другое количество товаров, но нет, поставьте хоть 5, хоть 100, мч отказывается показывать другое количество.

И, что тоже немного странно, переменная *|PRODUCT:PRICE|* заменяется на значения формата RUB288, и поменять это тоже почему-то нельзя, но об этом позже.

Для разнообразия мы пытались подставить еще и переменные с количеством игр и с общей стоимостью заказа, которые передаем по api, но мейлчимп и тут сказал «нет». Что ж, да будет так.

Слово программисту :)


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

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

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

Получив задачу на интеграцию, надо первым делом осмотреться. Что нам дано? Во-первых, сервис мэйлчимп, с которым надо подружиться. Идем на гитхаб и видим, что там достаточно много реализаций. Но выбор прост — у самого популярного пакета 1.5к звезд (drewm/mailchimp-api).

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

Во-вторых, нам дана документация. Исходя из документации, у нас есть ресурс Store с вложенными ресурсами: Cart, Customer, Order, Product, Promo rule. Для брошенной корзины без рекомендованных товаров нам понадобятся только Product, Cart и Customer. Cart в свою очередь состоит из набора Cart line, а Product содержит Product variants.

Мы декомпозировали задачу следующим образом:

  1. Загрузить данные по магазину в ресурс Stores
  2. Загрузить все доступные к покупке товары в Products
  3. Настроить загрузку корзин с пользователями по расписанию

Ок, поехали. Первым делом беремся за сущность «магазин». Мы решили сразу использовать тестовый и боевой вариант магазина и, в зависимости от переменной окружения, отвечающей за дев/прод режим, мы работали либо с одним магазином, либо с другим.

Чтобы загрузить данные по магазину, мы стучимся post-запросом по адресу /ecommerce/stores со следующим набором параметров:

[
   'id' => 'dev.***.ru',
   'list_id' => '****',
   'name' => '*** - test',
   'domain' => 'dev.***.ru',
   'email_address' => 'admin@***.ru',
   'currency_code' => 'RUB',
   'primary_locale' => 'ru',
   'money_format' => '₽',
]

Параметров несколько больше, но все зависит от потребностей. Т.к. мы не собирались использовать контактные данные магазина в письмах, то не заполнили поля phone, address, timezone и т. п.
Но нас ожидал небольшой сюрприз. Поле money_format вроде специально создано для возможности представить цену в удобном нам формате. Но при построении шаблона брошенной корзины мэйлчимп упорно подставляет RUB перед числом. Мэйлчимп, перестань!

После загрузки мы можем проверить данные с помощью get-запроса по адресу /ecommerce/stores, чтобы увидеть все загруженные магазины, либо /ecommerce/stores/{id} для получения данных по конкретному магазину.

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

Так-с, чтобы МЧ мог подставлять товары в брошенную корзину, надо ему скормить эти товары. Для этого у нас есть адрес /ecommerce/stores/{store_id}/products, куда мы отправляем post-запросы на создание продуктов в системе.


[
   'id' => '742',
   'title' => 'Кастрюля',
   'handle' => 'kastrulya',
   'url' => 'http://***.ru/catalog/kastrulya/',
   'description' => 'Кастрюля — незаменимая вещь на кухне. Купив кастрюлю, вы измените    свою жизнь в лучшую сторону. Вы поймете, что невозможно прожить без этой вещи и дня! В каждый дом по кастрюле и пусть никто не уйдет обиженным!',
   'type' => 'Посуда',
   'vendor' => 'Рога и Копыта',
   'image_url' => 'http://***.ru/images/742/product.png',
   'variants' => [
       [
           'id' => '742',
           'title' => 'Кастрюля',
           'url' => 'http://***.ru/catalog/kastrulya/',
           'price' => 890,
           'sku' => 'KA453',
           'inventory_quantity' => 1000,
           'image_url' => 'http://***.ru/images/742/product.png',
           'visibility' => 'visible',
       ],
   ],
]

Что тут примечательного? Ну во-первых, каждый товар должен состоять хотя бы из одного товарного предложения. По сути Product — это некий контейнер для загрузки товарных предложений. Причем, id товара и товарного предложения могут пересекаться, т. к. это разные ресурсы в api МЧ.

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

Поле handle было описано как «the handle of a product». Ок, посовещавшись мы решили, что это часть урла, относящегося к самому продукту (чпу). Но это подтвердилось только в ходе тестов.

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

И тут у нас возникла проблема, товары почему-то не отображались в темплейтах мейлчимпа.

Начали рыться в доке по Product. И нашли поле visibility с роскошным описанием:

описание поля visibility

Ну ок, тип String! А что туда можно передавать? Почему нельзя описать все возможные значения?! Я ведь могу туда отправить, например, «show me pls!».

Благо есть пример запроса!

пример запроса

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

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

Дальше перед нами стоит задача загрузки брошенных корзин в МЧ. Изначально в голову пришло 2 варианта:

  1. При каждом изменении корзины (добавление/удаление товара), мы повторяем это действие в МЧ. Из минусов — сразу напрашивается огромное количество запросов к внешнему сервису.
  2. Раз в n минут смотреть корзины, которые не менялись более часа назад. После чего отправляем их в МЧ. Проблема только одна — следить за корзинами, которые были возобновлены после того, как отправились в МЧ.

Для начала делаем запрос в нашу базу данных (далее БД) за нашими данными в окне от 1 часа до 3. Почему 3? Через час после последнего изменения мы отправляем корзину в систему. В МЧ настроен минимально возможный интервал отправки корзины — 1 час. Поэтому в теории через 2 часа ± 5 минут произойдет отправка письма. Так что 3 часа — это величина даже с запасом.

Получив данные из БД, мы делаем get-запрос по адресу /ecommerce/stores/{store_id}/carts. Таким образом мы получаем все корзины, которые сидят в системе e-commerce и ждут своей очереди на отправку (либо уже отправлены). Для чего нам это нужно? Нужно для синхронизации с нашими данными. Мы отправим все корзины, полученные из БД, но нам нужно удалить те, которые уже не находятся в промежутке 1-3 часа. После 3х — уже неактуальные данные. До часа — корзины, к��торые могли опять возобновить, либо оформить заказ.

Для удаления нам надо просто найти разницу между двумя массивами/коллекциями корзин.
Получив корзины, которые необходимо удалить, мы отправляем delete-запрос /ecommerce/stores/{store_id}/carts/{cart_id}.

Дальше берем корзины для загрузки и циклом отправляем их post-запросами в систему.

Параметры корзины выглядят как-то так:


[
   'id' => '1207',
   'customer' =>
       [
           'id' => '25',
           'email_address' => 'email@example.com',
           'opt_in_status' => false,
       ],
   'currency_code' => 'RUB',
   'order_total' => 1597,
   'checkout_url' => 'http://***.ru/cart/abandoned/?cart=eyJpdGVtcyI6eyI1OTgwIjoxLCIzNDA0IjoxLCI3NzMiOjEsIjkwNTgiOjEsIjkwOTEiOjEsIjE4ODciOjEsIjc4NCI6MSwiNTExMSI6MSwiODA1MyI6MSwiMTk0MSI6MSwiNTQ0NSI6MSwiNzk1NCI6MywiOTA2NyI6NCwiOTA2NSI6NCwiNzg0MyI6MSwiOTA2NiI6M30sInByb21vY29kZSI6bnVsbH0%253D',
   'lines' => [
       0 => [
           'id' => '123',
           'product_id' => '5980',
           'product_variant_id' => '5980',
           'quantity' => 1,
           'price' => 841,
       ],
       1 => [
           'id' => '124',
           'product_id' => '3404',
           'product_variant_id' => '3404',
           'quantity' => 1,
           'price' => 756,
       ],
   ],
]

И опять наша любимая рубрика «догадайся, как работают эти поля». Например, методом научного тыка было выявлено, что можно не создавать покупателя отдельным запросом. Надо передать минимально требуемый набор полей, и он автоматически создастся, если его не было в системе. В нашем случае мы ограничились id, email, opt_in_status. Последний параметр отвечает за состояние подписки юзера в нашем листе. Если он true, то это означает состояние subscribed, в противном случае transactional.

Список товаров без проблем загружается через массив Cart Lines, который в свою очередь является ресурсом сущности Cart. Т.е. мы можем отдельно управлять этим набором с помощью rest-запросов.

Ну вот вроде бы и все? А вот и нет.

При тестировании мы обратили внимание, что отправив одну и ту же корзину, она отправляется лишь раз. Хотя мы ее удаляли из системы и загружали заново. Нигде ничего не сказано, ни единого слова! В итоге опытным путем, с помощью какой-то матери, мы приняли за основу гипотезу, что корзина с одним и тем же id может быть отправлена только один раз.

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

После того, как мы все это проделали, МЧ начал присылать нам красивые письма о брошенной корзине. И тут появился второй вопрос. Если юзер, бросил корзину и вернулся из письма в свой же аккаунт, т. е. он был авторизован в момент перехода по ссылке, то он попадет в свою корзину без проблем. А так получается, что письмо говорит тебе «на! возьми свою корзину назад!», а мы при переходе ему говорим «упс, чего-то потерялося все! Мы ничего не трогали! Оно само!»

Было решено кодировать состав корзины в строку и передавать в checkout_url при отправке корзины в МЧ. А при переходе на сайт ловить эту строку, декодировать и накидывать все товары в корзину, не забыв перед этим ее полностью обнулить.

Таким образом, в какой бы браузер мы не отправили юзера, он получает свою корзину, как мы и обещали. Единственный минус, что ему остается только авторизоваться. Но авторизовывать через ссылку — это моветон, да и вообще дело опасное, в первую очередь для наших клиентов.
Что в итоге? В принципе, проблем особых не было при реализации, так как очень часто выручали ответы МЧ при ошибках, связанных с валидацией переданных полей. Но их было бы еще меньше, если бы они нормально по-человечьи описали все эти тонкости работы МЧ и более подробно описали бы поля.

Настройка отчета в Google.Analytics


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

Чтобы собрать новый отчет под брошенную корзину, идем в «Мои отчеты»:

Мои отчеты

Дальше «Добавить отчет»:

Добавить отчет

А после добавляем параметры, которые будем отслеживать. Мы решили, что будем смотреть на брошенную корзину в разрезе городов, у вас может быть другое видение.

У мейлчимпа стандартной кампанией для брошенной корзины является ABANDONED_CART_EMAIL, подставляем ее в фильтр и получаем отчет.

image

That’s all forks!

Теперь у вас настроена отправка брошенной корзины и отчет, по которому вы можете смотреть выхлоп с нее. И тестируйте, тестируйте, тестируйте! ;)