diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/Block/Catalog/Product/ViewedProduct.php b/Block/Catalog/Product/ViewedProduct.php new file mode 100644 index 0000000..067acac --- /dev/null +++ b/Block/Catalog/Product/ViewedProduct.php @@ -0,0 +1,76 @@ +_helper = $helper; + $this->_objectManager = $objectManager; + $this->_registry = $registry; + $this->_categoryFactory = $categoryFactory; + } + + /** + * Grab the Klaviyo public API key from the configuration helper and return it. + * Used to make `identify` calls for `Active on Site` metric (for signed in users) + * and `track` calls for `Viewed Product` metrics. + * + * @return string + */ + public function getPublicApiKey() + { + return $this->_helper->getPublicApiKey(); + } + + /** + * Grab whether the Klaviyo_Reclaim extension is enabled through Admin from + * the configuration helper and return it. + * + * @return boolean + */ + public function isKlaviyoEnabled() + { + return $this->_helper->getEnabled(); + } + + /** + * View helper to return the currently viewed catalog product. Used to track + * the `Viewed Product` metric. + * + * @return Catalog_Product + */ + public function getProduct() + { + return $this->_registry->registry('current_product'); + } + + /** + * View helper to return a list of category names for the currently viewed + * catalog product. Used to track the `Viewed Product` metric. + * + * @return JSON + */ + public function getProductCategoriesAsJson() + { + $categories = array(); + foreach ($this->getProduct()->getCategoryIds() as $category_id) { + $category = $category = $this->_categoryFactory->create()->load($category_id); + $categories[] = $category->getName(); + } + return json_encode($categories); + } +} diff --git a/Block/Initialize.php b/Block/Initialize.php new file mode 100644 index 0000000..04f10a3 --- /dev/null +++ b/Block/Initialize.php @@ -0,0 +1,91 @@ +_helper = $helper; + $this->_objectManager = $objectManager; + } + + /** + * Grab the Klaviyo public API key from the configuration helper and return it. + * Used to make `identify` calls for `Active on Site` metric (for signed in users) + * and `track` calls for `Viewed Product` metrics. + * + * @return string + */ + public function getPublicApiKey() + { + return $this->_helper->getPublicApiKey(); + } + + /** + * Grab whether the Klaviyo_Reclaim extension is enabled through Admin from + * the configuration helper and return it. + * + * @return boolean + */ + public function isKlaviyoEnabled() + { + return $this->_helper->getEnabled(); + } + + /** + * View helper to see if the current user is logged it. Used to know whether + * we can send an `identify` call for the `Active on Site` metric. + * + * @return boolean + */ + public function isLoggedIn() + { + $customerSession = $this->_objectManager->create('Magento\Customer\Model\Session'); + return $customerSession->isLoggedIn(); + } + + /** + * View helper to get the current users email address. Used to send an + * `identify` call for the `Active on Site` metric. + * + * @return string + */ + public function getCustomerEmail() + { + $customerSession = $this->_objectManager->create('Magento\Customer\Model\Session'); + return $customerSession->getCustomerData()->getEmail(); + } + + /** + * View helper to get the current users first name. Used to send an + * `identify` call for the `Active on Site` metric. + * + * @return string + */ + public function getCustomerFirstname() + { + $customerSession = $this->_objectManager->create('Magento\Customer\Model\Session'); + return $customerSession->getCustomerData()->getFirstname(); + } + + /** + * View helper to get the current users last name. Used to send an + * `identify` call for the `Active on Site` metric. + * + * @return string + */ + public function getCustomerLastname() + { + $customerSession = $this->_objectManager->create('Magento\Customer\Model\Session'); + return $customerSession->getCustomerData()->getLastname(); + } +} diff --git a/Block/System/Config/Form/Field/Newsletter.php b/Block/System/Config/Form/Field/Newsletter.php new file mode 100644 index 0000000..1dabfd3 --- /dev/null +++ b/Block/System/Config/Form/Field/Newsletter.php @@ -0,0 +1,22 @@ +getValues(); + + if (sizeof($values) > 1) return parent::_getElementHtml($element); + + $message = '

' . $values[0]['label'] . '

'; + + $html = ''; + + return $html; + } +} \ No newline at end of file diff --git a/Controller/Checkout/Cart.php b/Controller/Checkout/Cart.php new file mode 100644 index 0000000..2dedb44 --- /dev/null +++ b/Controller/Checkout/Cart.php @@ -0,0 +1,47 @@ +quoteRepository = $quoteRepository; + $this->resultRedirectFactory = $context->getResultRedirectFactory(); + $this->cart = $cart; + $this->request = $context->getRequest(); + + parent::__construct($context); + } + + /** + * Endpoint /reclaim/checkout/cart resolves here. This endpoint will load an existing + * quote into the current Customer's cart and redirect the Customer to checkout/cart + * If no quote is found it will not do anything to the Customer's cart + * + * @return JSON + */ + public function execute() + { + $quoteId = $this->request->getParam('quote_id'); + + try { + $quote = $this->quoteRepository->get($quoteId); + $this->cart->setQuote($quote); + $this->cart->save(); + } catch (\Magento\Framework\Exception\NoSuchEntityException $ex) { + } + + $redirect = $this->resultRedirectFactory->create(); + $redirect->setPath('checkout/cart'); + return $redirect; + } +} diff --git a/Controller/Checkout/Email.php b/Controller/Checkout/Email.php new file mode 100644 index 0000000..569dd67 --- /dev/null +++ b/Controller/Checkout/Email.php @@ -0,0 +1,34 @@ +resultJsonFactory = $resultJsonFactory; + $this->_objectManager = $context->getObjectManager(); + } + + /** + * Endpoint /reclaim/checkout/email resolves here. A quote's email address + * is AJAX'd here after the email input changes. We look up the current + * quote and save the email on it, since Magento doesn't do that on its own. + * + * @return JSON + */ + public function execute() + { + $result = $this->resultJsonFactory->create(); + $quote = $this->_objectManager->create('Magento\Checkout\Model\Cart')->getQuote(); + + $customer_email = $this->getRequest()->getParam('email'); + $quote->setCustomerEmail($customer_email); + $quote->save(); + + return $result->setData(['success' => $quote->getData()]); + } +} diff --git a/Controller/Checkout/Reload.php b/Controller/Checkout/Reload.php new file mode 100644 index 0000000..a5ee5c7 --- /dev/null +++ b/Controller/Checkout/Reload.php @@ -0,0 +1,28 @@ +resultJsonFactory = $resultJsonFactory; + parent::__construct($context); + } + /** + * Enpoint /reclaim/checkout/reload resolves here. This endpoint is used indirectly + * via sections.xml so that the minicart can be updated on the client side on request + * + * @return JSON + */ + public function execute() + { + $result = $this->resultJsonFactory->create(); + + return $result->setData(['success' => 1]); + } +} diff --git a/Helper/Data.php b/Helper/Data.php new file mode 100644 index 0000000..74fcf07 --- /dev/null +++ b/Helper/Data.php @@ -0,0 +1,124 @@ +_scopeConfig = $context->getScopeConfig(); + } + + public function getEnabled(){ + return $this->_scopeConfig->getValue(self::ENABLE); + } + + public function getPublicApiKey(){ + return $this->_scopeConfig->getValue(self::PUBLIC_API_KEY); + } + + public function getPrivateApiKey(){ + return $this->_scopeConfig->getValue(self::PRIVATE_API_KEY); + } + + public function getNewsletter(){ + return $this->_scopeConfig->getValue(self::NEWSLETTER); + } + + public function getKlaviyoLists($api_key=null){ + if (!$api_key) $api_key = $this->getPrivateApiKey(); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, 'https://a.klaviyo.com/api/v1/lists?api_key=' . $api_key); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + + $output = json_decode(curl_exec($ch)); + curl_close($ch); + + if (property_exists($output, 'status')) { + $status = $output->status; + if ($status === 403) { + $reason = 'The Private Klaviyo API Key you have set is invalid.'; + } elseif ($status === 401) { + $reason = 'The Private Klaviyo API key you have set is no longer valid.'; + } else { + $reason = 'Unable to verify Klaviyo Private API Key.'; + } + + $result = [ + 'success' => false, + 'reason' => $reason + ]; + } else { + $static_groups = array_filter($output->data, function($list) { + return $list->list_type === 'list'; + }); + + usort($static_groups, function($a, $b) { + return strtolower($a->name) > strtolower($b->name) ? 1 : -1; + }); + + $result = [ + 'success' => true, + 'lists' => $static_groups + ]; + } + + return $result; + } + + public function subscribeEmailToKlaviyoList($email, $first_name=null, $last_name=null) { + $list_id = $this->getNewsletter(); + $api_key = $this->getPrivateApiKey(); + + $properties = []; + if ($first_name) $properties['$first_name'] = $first_name; + if ($last_name) $properties['$last_name'] = $last_name; + $properties_val = count($properties) ? urlencode(json_encode($properties)) : '{}'; + + $fields = [ + 'api_key=' . $api_key, + 'email=' . urlencode($email), + 'confirm_optin=false', + 'properties=' . $properties_val, + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, 'https://a.klaviyo.com/api/v1/list/' . $list_id . '/members'); + curl_setopt($ch, CURLOPT_POST, count($fields)); + curl_setopt($ch, CURLOPT_POSTFIELDS, join('&', $fields)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + + curl_exec($ch); + curl_close($ch); + } + + public function unsubscribeEmailFromKlaviyoList($email) { + $list_id = $this->getNewsletter(); + $api_key = $this->getPrivateApiKey(); + + $fields = [ + 'api_key=' . $api_key, + 'email=' . urlencode($email), + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, 'https://a.klaviyo.com/api/v1/list/' . $list_id . '/members/exclude'); + curl_setopt($ch, CURLOPT_POST, count($fields)); + curl_setopt($ch, CURLOPT_POSTFIELDS, join('&', $fields)); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + + curl_exec($ch); + curl_close($ch); + } +} diff --git a/Helper/ListOptions.php b/Helper/ListOptions.php new file mode 100644 index 0000000..b676790 --- /dev/null +++ b/Helper/ListOptions.php @@ -0,0 +1,59 @@ +messageManager = $messageManager; + $this->data_helper = $data_helper; + } + + /** + * @return array + */ + public function toOptionArray() + { + // This is a bit hacky. We need to ultimately provide the reason to the custom + // field in Klaviyo\Reclaim\Block\System\Config\Form\Field\Newsletter, so we pass + // it over in the options array. + + if (!$this->data_helper->getPrivateApiKey()) { + return [[ + 'label' => 'To sync newsletter subscribers to Klaviyo, first save a Private Klaviyo API Key on the "General" tab.', + 'value' => 0 + ]]; + } + + $result = $this->data_helper->getKlaviyoLists(); + if (!$result['success']) { + return [[ + 'label' => $result['reason'] . ' To sync newsletter subscribers to Klaviyo, update the Private Klaviyo API Key on the "General" tab.', + 'value' => 0 + ]]; + } + + if (!count($result['lists'])) { + return [[ + 'label' => 'You don\\\'t have any Klaviyo lists. Please create one first at https://www.klaviyo.com/lists/create and then return here to select it.', + 'value' => 0 + ]]; + } + + $options = array_map(function($list) { + return ['label' => $list->name, 'value' => $list->id]; + }, $result['lists']); + + $default_value = [ + 'label' => 'Select a list...', + 'value' => 0 + ]; + array_unshift($options, $default_value); + + return $options; + } +} \ No newline at end of file diff --git a/Observer/CustomerRegistrationObserver.php b/Observer/CustomerRegistrationObserver.php new file mode 100644 index 0000000..6e62e4a --- /dev/null +++ b/Observer/CustomerRegistrationObserver.php @@ -0,0 +1,31 @@ +data_helper = $data_helper; + $this->request = $request; + } + + public function execute(\Magento\Framework\Event\Observer $observer) + { + if (!$this->data_helper->getEnabled()) return; + if (!$this->request->getParam('is_subscribed')) return; + + $this->data_helper->subscribeEmailToKlaviyoList( + $this->request->getParam('email'), + $this->request->getParam('firstname'), + $this->request->getParam('lastname') + ); + } +} \ No newline at end of file diff --git a/Observer/NewsletterSubscribeObserver.php b/Observer/NewsletterSubscribeObserver.php new file mode 100644 index 0000000..1ac5eff --- /dev/null +++ b/Observer/NewsletterSubscribeObserver.php @@ -0,0 +1,27 @@ +data_helper = $data_helper; + $this->request = $request; + } + + public function execute(\Magento\Framework\Event\Observer $observer) + { + if (!$this->data_helper->getEnabled()) return; + + $email = $this->request->getParam('email'); + $this->data_helper->subscribeEmailToKlaviyoList($email); + } +} \ No newline at end of file diff --git a/Observer/PrivateApiKeyObserver.php b/Observer/PrivateApiKeyObserver.php new file mode 100644 index 0000000..ada036b --- /dev/null +++ b/Observer/PrivateApiKeyObserver.php @@ -0,0 +1,38 @@ +messageManager = $messageManager; + $this->data_helper = $data_helper; + } + + public function execute(\Magento\Framework\Event\Observer $observer) + { + $field = $observer->getEvent()['config_data']->getData(); + + if ($field['field'] !== 'private_api_key') return; + + $api_key = $field['value']; + if (!$api_key) return; + + $result = $this->data_helper->getKlaviyoLists($api_key); + + if ($result['success']) { + $this->messageManager->addSuccessMessage('Your Private Klaviyo API Key was successfully validated.'); + } else { + $this->messageManager->addErrorMessage($result['reason']); + } + } +} \ No newline at end of file diff --git a/Observer/UserProfileNewsletterSubscribeObserver.php b/Observer/UserProfileNewsletterSubscribeObserver.php new file mode 100644 index 0000000..326e86b --- /dev/null +++ b/Observer/UserProfileNewsletterSubscribeObserver.php @@ -0,0 +1,42 @@ +data_helper = $data_helper; + $this->request = $request; + } + + public function execute(\Magento\Framework\Event\Observer $observer) + { + if (!$this->data_helper->getEnabled()) return; + + $om = \Magento\Framework\App\ObjectManager::getInstance(); + $customerSession = $om->get('Magento\Customer\Model\Session'); + $customer = $customerSession->getCustomer(); + + $email = $customer->getEmail(); + + $is_subscribed = $this->request->getParam('is_subscribed'); + + if ($is_subscribed) { + $this->data_helper->subscribeEmailToKlaviyoList( + $email, + $customer->getFirstname(), + $customer->getLastname() + ); + } else { + $this->data_helper->unsubscribeEmailFromKlaviyoList($email); + } + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..32658b7 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Klaviyo for Magento 2 + +Klaviyo extension for Magento 2. Allows pushing newsletters to Klaviyo's platform and more. + +## Features + +- **Identifies users** + - Go to sign in or create account + - Sign in or create account + - Go to another page and inspect source for identify call with email, firstname, and lastname + +- **Tracks viewing an item (catalog product)** + - Only will work if user is signed in + - Inspect page source, find `learnq` snippet and see what PHP is echoing out + - Should be a `Viewed Product` track call with product details + +- **Saves checkout emails** + - Add some items to your cart and go to checkout page + - In console see: `Klaviyo_Reclaim - Binding to #customer-email` + - Change email + - See `Klaviyo_Reclaim - Quote updated with customer email: your.name@klaviyo.com` + - Make sure AJAX call comes back with checkout / quoute JSON + +- **Sync Newsletter (Un)Subscribes to a Klaviyo List** + - This feature covers workflows where a Customer (un)subscribes from the following places: + - Box at the bottom of every page + - On account creation + - Through their account settings + +- **Abandoned Cart** + - Given a quote ID, a URL can be crafted that will load a Customer's cart with a quote + +## Prerequisities + +Magento 2 + +### Setup + - From admin: + - Go to stores > configuration + - Find Klaviyo in sidebar + - Open General + - Enable Klaviyo + - Add 6 digit Klaviyo public API key + - For syncing Newsletter Subscribe/Unsubscribes: also add your Klaviyo private API key + - Save config + - For syncing Newsletter Subscribe/Unsubscribes: + - Open Newsletter from the sidebar + - The page should load with your lists from Klaviyo + - Select a list + - Save config + +## Support + +Contact support@klaviyo.com diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4044335 --- /dev/null +++ b/composer.json @@ -0,0 +1,14 @@ +{ + "name": "klaviyo/magento2-extension", + "description": "Klaviyo extension for Magento 2. Allows pushing newsletters to Klaviyo's platform and more.", + "type": "magento2-module", + "version": "1.0.0", + "autoload": { + "files": [ + "registration.php" + ], + "psr-4": { + "Klaviyo\\Reclaim\\": "" + } + } +} diff --git a/etc/adminhtml/events.xml b/etc/adminhtml/events.xml new file mode 100644 index 0000000..5fe14b9 --- /dev/null +++ b/etc/adminhtml/events.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/etc/adminhtml/routes.xml b/etc/adminhtml/routes.xml new file mode 100644 index 0000000..36c0e3c --- /dev/null +++ b/etc/adminhtml/routes.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml new file mode 100644 index 0000000..1d30cf8 --- /dev/null +++ b/etc/adminhtml/system.xml @@ -0,0 +1,42 @@ + + + + + + + +
+ + klaviyo + Klaviyo_Reclaim::klaviyo_reclaim_general + + + + + Magento\Config\Model\Config\Source\Yesno + + + + + + + + +
+ +
+ + klaviyo + Klaviyo_Reclaim::klaviyo_reclaim + + + + + Klaviyo\Reclaim\Helper\ListOptions + Klaviyo\Reclaim\Block\System\Config\Form\Field\Newsletter + + +
+ +
+
diff --git a/etc/frontend/events.xml b/etc/frontend/events.xml new file mode 100644 index 0000000..bb8245b --- /dev/null +++ b/etc/frontend/events.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/etc/frontend/routes.xml b/etc/frontend/routes.xml new file mode 100644 index 0000000..69481b4 --- /dev/null +++ b/etc/frontend/routes.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/etc/frontend/sections.xml b/etc/frontend/sections.xml new file mode 100644 index 0000000..3a63257 --- /dev/null +++ b/etc/frontend/sections.xml @@ -0,0 +1,6 @@ + + + +
+ + diff --git a/etc/module.xml b/etc/module.xml new file mode 100644 index 0000000..af74783 --- /dev/null +++ b/etc/module.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/klaviyo-reclaim-1.0.0.zip b/klaviyo-reclaim-1.0.0.zip new file mode 100644 index 0000000..5564a30 Binary files /dev/null and b/klaviyo-reclaim-1.0.0.zip differ diff --git a/module.xml b/module.xml new file mode 100644 index 0000000..c1ae162 --- /dev/null +++ b/module.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/registration.php b/registration.php new file mode 100644 index 0000000..7e5bbca --- /dev/null +++ b/registration.php @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/view/frontend/layout/checkout_cart_index.xml b/view/frontend/layout/checkout_cart_index.xml new file mode 100644 index 0000000..96ea5f6 --- /dev/null +++ b/view/frontend/layout/checkout_cart_index.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/view/frontend/layout/checkout_index_index.xml b/view/frontend/layout/checkout_index_index.xml new file mode 100644 index 0000000..e51ca91 --- /dev/null +++ b/view/frontend/layout/checkout_index_index.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + Klaviyo_Reclaim/js/view/checkout/email + + + + + + + + + + + \ No newline at end of file diff --git a/view/frontend/layout/default.xml b/view/frontend/layout/default.xml new file mode 100644 index 0000000..3a24151 --- /dev/null +++ b/view/frontend/layout/default.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/view/frontend/templates/analytics/initialize.phtml b/view/frontend/templates/analytics/initialize.phtml new file mode 100644 index 0000000..db71be6 --- /dev/null +++ b/view/frontend/templates/analytics/initialize.phtml @@ -0,0 +1,16 @@ +isKlaviyoEnabled() && $this->getPublicApiKey() ): ?> + + diff --git a/view/frontend/templates/checkout/cart.phtml b/view/frontend/templates/checkout/cart.phtml new file mode 100644 index 0000000..41fe2a2 --- /dev/null +++ b/view/frontend/templates/checkout/cart.phtml @@ -0,0 +1,13 @@ +isKlaviyoEnabled() && $this->getPublicApiKey() ): ?> + + diff --git a/view/frontend/templates/product/viewed.phtml b/view/frontend/templates/product/viewed.phtml new file mode 100644 index 0000000..e18e102 --- /dev/null +++ b/view/frontend/templates/product/viewed.phtml @@ -0,0 +1,46 @@ +isKlaviyoEnabled() && $this->getPublicApiKey() ): ?> + getProduct(); + $_product_image_url = null; + // Check to see if we have an image for this product. + foreach ($_product->getMediaGalleryImages() as $_product_image) { + if (!$_product_image->getDisabled()) { + $_product_image_url = $_product_image->getUrl(); + break; + } + } + + $price = $_product->getPrice(); + + if ($_product->getTypeId() == "configurable") { + $_children = $_product->getTypeInstance()->getUsedProducts($_product); + foreach ($_children as $child){ + $price = $child->getPrice(); + if ($price) { + break; + } + } + } + ?> + + + + diff --git a/view/frontend/web/js/view/checkout/email.js b/view/frontend/web/js/view/checkout/email.js new file mode 100644 index 0000000..fc5c9b9 --- /dev/null +++ b/view/frontend/web/js/view/checkout/email.js @@ -0,0 +1,32 @@ +define([ + 'uiComponent', + 'jquery', + 'jquery/ui' +], function(Component) { + 'use strict'; + + return Component.extend({ + initialize: function () { + this._super(); + this.bindEmailListener(); + return this; + }, + + bindEmailListener: function() { + console.log('Klaviyo_Reclaim - Binding to #customer-email'); + jQuery('#maincontent').delegate('#customer-email', 'change', function(event) { + var customer_email = jQuery(this).val(); + jQuery.ajax({ + url: window.location.protocol + '//' + window.location.hostname + '/reclaim/checkout/email', + method: 'POST', + data: { + 'email' : customer_email + }, + success: function(data) { + console.log('Klaviyo_Reclaim - Quote updated with customer email: ' + customer_email); + } + }); + }); + } + }); +});