'Klaviyo', 'visible' => true, 'class_name' => 'AdminKlaviyoPsConfig', 'parent_class_name' => 'CONFIGURE', 'icon' => 'trending_up', ), ); /** @var string[] Custom checkout module controller page names and corresponding input selectors. */ const CUSTOM_CHECKOUTS_SELECTORS = array( 'module-supercheckout-supercheckout' => 'input[name="supercheckout_email"]', 'module-thecheckout-order' => 'input[type="email"]', ); /** * List of transactional email templates that will not be sent * * @var string[] */ const STATIC_TRANSACTIONAL_EMAIL_TEMPLATES = [ 'order_conf', 'download_product', // blocking offline payment methods transactional emails 'cheque', 'bankwire', 'cashondelivery', ]; /** * @var static */ protected static $instance = null; /** * It's a Symfony service container * We use our service container when it's impossible to use the service container of PrestaShop * It's better to use the service container of PrestaShop when it's possible * * @var ServiceContainer */ protected $klaviyoContainer = null; /** * During the build process, Webpack generates a manifest.json file that includes a list of all the output files created. * Each output file has a unique versioned name, which allows use to get the correct filename. * * @var array|null */ protected $cacheManifest = null; /** * Klaviyo constructor. */ public function __construct() { self::$instance = $this; $this->tab = 'advertising_marketing'; $this->need_instance = 0; $this->bootstrap = true; parent::__construct(); $this->description = $this->l('Klaviyo module to integrate PrestaShop with Klaviyo.', 'klaviyopsmodule'); $this->confirmUninstall = $this->l('Are you sure you want to uninstall?', 'klaviyopsmodule'); if (!Configuration::get('KLAVIYO')) { $this->warning = $this->l('No name provided', 'klaviyopsmodule'); } } /** * @return self */ public static function getInstance() { // Theoretically, is not supposed to be null. // However, if this is not the case, then this will make sure of it. if (self::$instance === null) { // This is a workaround to retrieve the current module name $modulePath = dirname(__FILE__, 2); $moduleName = basename($modulePath); self::$instance = Module::getInstanceByName($moduleName); } return self::$instance; } /** * @return bool * @throws PrestaShopException */ public function install() { if (Shop::isFeatureActive()) { Shop::setContext(Shop::CONTEXT_ALL); } if ( Configuration::hasKey('KLAVIYO') && Configuration::get('KLAVIYO') !== $this->name ) { $this->_errors[] = $this->l('You cannot install the Klaviyo addon and the PrestaShop Automation addon simultaneously.'); return false; } // Register hooks first to set up custom webservices so we can set permissions to custom resources. return parent::install() && Configuration::updateValue('KLAVIYO', $this->name) && Configuration::updateValue('KLAVIYO_REAL_TIME_EVENT_ENABLE', 0) && Configuration::updateValue('KLAVIYO_TRANSACTIONAL_EMAIL_ENABLE', 0) && Configuration::updateValue('KLAVIYO_COUPON_USAGE_LIMIT_TYPE', KlaviyoCouponUsageLimitType::LIMIT_PREFIX) && $this->registerControllersAndHooks() && $this->setupWebservice() && $this->installTabs(); } /** * Install all Tabs. * * @return bool */ public function installTabs() { foreach (static::ADMIN_CONTROLLERS as $adminTab) { if (false === $this->installTab($adminTab)) { return false; } } return true; } /** * Install Tab. * * @param array $tabData * @return bool */ public function installTab(array $tabData) { $tab = new Tab(); $tab->id_parent = Tab::getIdFromClassName($tabData['parent_class_name']); $tab->name = array_fill_keys(array_values(Language::getIDs(false)), $tabData['name']); $tab->class_name = $tabData['class_name']; // Makes the module accessible in the Admin Controller, see ModuleAdminControllerCore constructor. $tab->module = $this->name; $tab->active = (bool) $tabData['visible']; $tab->icon = $tabData['icon']; return $tab->save(); } /** * Turn on webservice, create token and set permissions. * * @return bool */ protected function setupWebservice() { if ( !Configuration::updateValue('PS_WEBSERVICE', true) || !$this->createWebserviceKey() ) { return false; } return true; } /** * Auto-setup webservice key and permissions for API access. * * @return bool */ protected function createWebserviceKey() { $existingKey = Configuration::get('KLAVIYO_WEBSERVICE_KEY'); // If we've already created a key, just pass. if ( $existingKey && WebserviceKey::keyExists($existingKey) ) { return true; } // Create and set the WebserviceKey object properties $webservice = new WebserviceKey(); $key = Tools::passwdGen(32); $webservice->key = $key; $webservice->description = 'Klaviyo webservice key'; // Save webservice key if ( !$webservice->add() || !Configuration::updateValue('KLAVIYO_WEBSERVICE_ID', $webservice->id) || !Configuration::updateValue('KLAVIYO_WEBSERVICE_KEY', $webservice->key) ) { $this->_errors[] = $this->l('It was not possible to install the Klaviyo module: webservice key creation error.', 'klaviyopsmodule'); return false; } // Set webservice key permissions if (!$this->setWebservicePermissionsForAccount($webservice->id, $this->getWebservicePermissions())) { $this->_errors[] = $this->l('It was not possible to install the Klaviyo module: webservice key permissions setup error.', 'klaviyopsmodule'); return false; } return true; } /** * Get Webservice permissions for Klaviyo Webservice key. * * @return string[][] */ protected function getWebservicePermissions() { return array( 'klaviyo' => array('GET', 'PUT', 'POST', 'DELETE', 'HEAD'), ); } /** * Set permissions for resources on auto-created webservice token. * * HACK - Sadly, the built-in WebserviceKey::setPermissionForAccount method doesn't work in this flow. We register * our custom 'klaviyo' resource earlier in the installation process which should get picked up by the * addWebserviceResources hook in WebserviceRequest::getResources but it doesn't, our method to handle that hook * doesn't fire during installation (works fine later). So this method essentially does the same permission setting * but doesn't match against the available resources because our custom resource isn't available yet. We won't set * permissions if we can't register the hook because installation will fail earlier. * * @param $webserviceId * @param $permissionsToSet * @return bool * @throws PrestaShopDatabaseException * @throws PrestaShopException */ protected function setWebservicePermissionsForAccount($webserviceId, $permissionsToSet) { $success = true; $sql = 'DELETE FROM `' . _DB_PREFIX_ . 'webservice_permission` WHERE `id_webservice_account` = ' . (int) $webserviceId; if (!Db::getInstance()->execute($sql)) { $success = false; } if (isset($permissionsToSet)) { $permissions = array(); $methods = array('GET', 'PUT', 'POST', 'DELETE', 'HEAD'); foreach ($permissionsToSet as $resource_name => $resource_methods) { foreach ($resource_methods as $method_name) { if (in_array($method_name, $methods)) { $permissions[] = array($method_name, $resource_name); } } } $account = new WebserviceKey($webserviceId); if ($account->deleteAssociations() && $permissions) { $sql = 'INSERT INTO `' . _DB_PREFIX_ . 'webservice_permission` (`id_webservice_permission` ,`resource` ,`method` ,`id_webservice_account`) VALUES '; foreach ($permissions as $permission) { $sql .= '(NULL , \'' . pSQL($permission[1]) . '\', \'' . pSQL($permission[0]) . '\', ' . (int) $webserviceId . '), '; } $sql = rtrim($sql, ', '); if (!Db::getInstance()->execute($sql)) { $success = false; } } } return $success; } /** * Register controllers and hooks. * * @return bool */ public function registerControllersAndHooks() { return $this->registerHook('moduleRoutes') && $this->registerHook('actionCustomerAccountAdd') && $this->registerHook('actionCustomerAccountUpdate') && // Custom hook from ps_emailsubscription module (default newsletter subscribe form). $this->registerHook('actionNewsletterRegistrationAfter') && $this->registerHook('addWebserviceResources') && $this->registerHook('actionAdminControllerSetMedia') && $this->registerHook('actionFrontControllerSetMedia') && $this->registerHook('actionEmailSendBefore') && $this->registerHook('actionOrderStatusPostUpdate') && $this->registerHook('additionalCustomerAddressFields') && $this->registerHook('actionSubmitCustomerAddressForm') && $this->registerHook('displayOrderConfirmation') && $this->registerHook('actionFrontControllerInitAfter'); } /** * @return bool */ public function uninstall() { if (Shop::isFeatureActive()) { Shop::setContext(Shop::CONTEXT_ALL); } if ( !parent::uninstall() || !Configuration::deleteByName('KLAVIYO') || !$this->deleteKlaviyoConfigurationKeys() || !$this->uninstallTabs() || !$this->unregisterControllersAndHooks() ) { return false; } return true; } /** * Delete module configuration keys. * * @return bool */ public function deleteKlaviyoConfigurationKeys() { return count(array_filter(self::CONFIG_KEYS, 'Configuration::deleteByName')) == count(self::CONFIG_KEYS); } /** * Unregister controllers and hooks on module uninstall. * * @return bool */ public function unregisterControllersAndHooks() { return $this->unregisterHook('moduleRoutes') && $this->unregisterHook('actionCustomerAccountAdd') && $this->unregisterHook('actionCustomerAccountUpdate') && // Custom hook from ps_emailsubscription module (default newsletter subscribe form). $this->unregisterHook('actionNewsletterRegistrationAfter') && $this->unregisterHook('addWebserviceResources') && $this->unregisterHook('actionAdminControllerSetMedia') && $this->unregisterHook('actionFrontControllerSetMedia') && $this->unregisterHook('actionEmailSendBefore') && $this->unregisterHook('actionOrderStatusPostUpdate') && $this->unregisterHook('displayAdminAfterHeader') && $this->unregisterHook('additionalCustomerAddressFields') && $this->unregisterHook('actionSubmitCustomerAddressForm') && $this->unregisterHook('displayOrderConfirmation') && $this->unregisterHook('actionFrontControllerInitAfter'); } /** * Uninstall all Tabs. * * @return bool */ public function uninstallTabs() { foreach (static::ADMIN_CONTROLLERS as $adminTab) { if (false === $this->uninstallTab($adminTab)) { $this->_errors[] = 'Failed to uninstall all tabs.'; return false; } } return true; } /** * Uninstall Tab. * * @param array $tabData * @return bool */ public function uninstallTab(array $tabData) { $tabId = Tab::getIdFromClassName($tabData['class_name']); $tab = new Tab($tabId); if (false === (bool) $tab->delete()) { return false; } return true; } /** * Validate form and handle requests for the displayForm action. */ public function getContent() { Tools::redirectAdmin($this->context->link->getAdminLink('AdminKlaviyoPsConfig')); } /** * Configuration::get() defaults to config values at higher shop scopes if key is not set. This pulls in config * values for our form that aren't actually set. Basically undoing this to return null if the specific shop scope * does not have a value set. * * @param $configKey * @return bool|string|null */ public function getConfigurationValueOrNull($configKey, $idLang = null) { // If Multi-store is not active, getContext() returns Shop::CONTEXT_SHOP. if (!Shop::isFeatureActive()) { return Configuration::get($configKey, $idLang); } if ($this->context->shop->getContext() === Shop::CONTEXT_SHOP && Configuration::hasKey($configKey, $idLang, null, $this->context->shop->id)) { return Configuration::get($configKey, $idLang, null, $this->context->shop->id); } elseif ($this->context->shop->getContext() === Shop::CONTEXT_GROUP && Configuration::hasKey($configKey, $idLang, Shop::getContextShopGroupID(true))) { return Configuration::get($configKey, $idLang, Shop::getContextShopGroupID(true)); } elseif ($this->context->shop->getContext() === Shop::CONTEXT_ALL) { return Configuration::get($configKey, $idLang); } return null; } /** * Retrieve the service * * @param string $serviceName * @return mixed */ public function getService($serviceName) { if ($this->klaviyoContainer !== null) { return $this->klaviyoContainer->getService($serviceName); } try { // Before PrestaShop 1.7.6, we can't define service in legacy context // So, te be able to use the current architecture, we need to use the container adapter // https://devdocs.prestashop-project.org/8/modules/concepts/services/#services-in-legacy-environment if (version_compare(_PS_VERSION_, '1.7.6', '<')) { throw new Exception(); } // Can throw Exception ServiceNotFoundException if there is a cache issue $service = $this->get($serviceName); // In some case Module::get can return false if the Symfony container is not available (some modules can broke it for example) if ($service === false) { throw new Exception(); } return $service; } catch (Exception $e) { $this->klaviyoContainer = new ServiceContainer( $this->name, $this->getLocalPath() ); return $this->getService($serviceName); } } /** * Get path URI to a file located in "dist" folder by using manifest.json * * @param string $name * @return string */ public function getDistPathUri($name) { $modulePath = $this->getLocalPath(); $moduleUrl = $this->getPathUri(); if ($this->cacheManifest === null) { $manifestData = file_get_contents("{$modulePath}dist/manifest.json"); $manifestData = json_decode($manifestData, true); $this->cacheManifest = $manifestData; } $distPath = $this->cacheManifest[$name]; switch ($this->context->controller->controller_type) { case 'admin': case 'moduleadmin': return "{$moduleUrl}dist{$distPath}"; default: return "modules/{$this->name}/dist{$distPath}"; } } /** * Setup custom routes. * * @return array */ public function hookModuleRoutes() { return [ 'module-klaviyo-reclaim' => [ 'rule' => 'klaviyo/reclaim/cart', 'keywords' => [], 'controller' => 'reclaim', 'params' => [ 'fc' => 'module', 'module' => $this->name, ] ], 'module-klaviyo-build' => [ 'rule' => 'klaviyo/reclaim/build-reclaim', 'keywords' => [], 'controller' => 'buildReclaim', 'params' => [ 'fc' => 'module', 'module' => $this->name, ] ], 'module-klaviyo-add-to-cart' => [ 'rule' => 'klaviyo/events/add-to-cart', 'keywords' => [], 'controller' => 'addToCart', 'params' => [ 'fc' => 'module', 'module' => $this->name, ] ] ]; } /** * Handle actionCustomerAccountAdd hook. * @param array $params */ public function hookActionCustomerAccountAdd(array $params) { $hooksHandler = new HooksHandler($this); $hooksHandler->handleActionCustomerAccount( $params, 'Account Created' ); } /** * Handle actionCustomerAccountUpdate hook. * @param array $params */ public function hookActionCustomerAccountUpdate(array $params) { $hooksHandler = new HooksHandler($this); $hooksHandler->handleActionCustomerAccount( $params, 'Account Updated' ); } /** * Handle actionNewsletterRegistrationAfter hook from ps_emailsubscription module. * * @param array $params */ public function hookActionNewsletterRegistrationAfter(array $params) { $hooksHandler = new HooksHandler($this); $hooksHandler->handleActionNewsletterSubscription($params); } /** * Handle addWebserviceResources hook. This method needs to return the webservice definition * for the new endpoint in order to register it properly in WebserviceRequestCore::getResources(). * * @param array $params * @return array[] */ public function hookAddWebserviceResources(array $params) { $hooksHandler = new HooksHandler($this); return $hooksHandler->handleAddWebserviceResources($params); } /** * Handle actionFrontControllerSetMedia hook. * Fires on all BackOffice pages. * Calls methods to inject javascript & CSS files & dependent data. * * @param $params */ public function hookActionAdminControllerSetMedia($params) { $this->context->controller->addCSS( $this->getDistPathUri('admin-global.css') ); } /** * Handle actionFrontControllerSetMedia hook. Fires on all Front Office pages * and calls methods to inject javascript files and dependent data. * * @param $params */ public function hookActionFrontControllerSetMedia($params) { if ($this->getConfigurationValueOrNull('KLAVIYO_PUBLIC_API')) { $this->setupKlaviyoAnalytics(); $this->setupProductEvents(); $this->setupStartedCheckout(); } } /** * Block the sending of transactional emails on the PrestaShop side to manage them on the Klaviyo side. * To do this, we block the mail if : * its template is in the STATIC_TRANSACTIONAL_EMAIL_TEMPLATES list * its Order status (which we get from the template and the subject of the mail) is mapped in the module configuration. */ public function hookActionEmailSendBefore($params) { try { // This is not supposed to happen if ( !isset($params['template']) || !isset($params['subject']) || !isset($params['idLang']) ) { throw new KlaviyoException('Missing data in $params'); } if ( (int)Configuration::get('KLAVIYO_REAL_TIME_EVENT_ENABLE') === 0 || (int)Configuration::get('KLAVIYO_TRANSACTIONAL_EMAIL_ENABLE') === 0 || !$this->getConfigurationValueOrNull('KLAVIYO_PUBLIC_API') ) { return true; } $template = $params['template']; $subject = $params['subject']; $idLang = (int)$params['idLang']; if (in_array($template, self::STATIC_TRANSACTIONAL_EMAIL_TEMPLATES, true)) { return false; } $sql = (new DbQuery()) ->select('osl.id_order_state') ->from('order_state_lang', 'osl') ->where('osl.template = "' . pSQL($template) . '"') ->where('osl.name = "' . pSQL($subject) . '"') ->where('osl.id_lang = ' . $idLang) ; $orderStates = Db::getInstance()->executeS($sql); if (!is_array($orderStates)) { return true; } foreach ($orderStates as $row) { $mappedOrderStatus = KlaviyoUtils::getMappedOrderStatusValue( (int)$row['id_order_state'] ); if ($mappedOrderStatus !== null) { return false; } } return true; } catch (Exception $e) { /** @var LoggerService $logger */ $logger = $this->getService('klaviyops.prestashop_services.logger'); $template = (isset($params['template'])) ? $params['template'] : 'no_template' ; $logger->error("An error occured in hookActionEmailSendBefore for {$template} email template"); return true; } } /** * Sending order event in real time to Klaviyo */ public function hookActionOrderStatusPostUpdate($params) { try { if ( !isset($params['id_order']) ) { throw new KlaviyoException('Missing data in $params'); } if ( (int)Configuration::get('KLAVIYO_REAL_TIME_EVENT_ENABLE') === 0 || !$this->getConfigurationValueOrNull('KLAVIYO_PUBLIC_API') ) { return; } /** @var OrderService $orderService */ $orderService = $this->getService('klaviyops.prestashop_services.order'); /** @var OrderEventService $orderEventService */ $orderEventService = $this->getService('klaviyops.klaviyo_service.order_event'); $order = new Order((int)$params['id_order']); if (!Validate::isLoadedObject($order)) { throw new KlaviyoException('Order is not valid'); } $order = $orderService->normalize($order); $orderEventService->track($order); } catch (Exception $e) { /** @var LoggerService $logger */ $logger = $this->getService('klaviyops.prestashop_services.logger'); $idOrder = (isset($params['id_order'])) ? $params['id_order'] : 0 ; $logger->error("An error occured in hookActionOrderStatusPostUpdate {$idOrder} order"); return true; } } /** * Display SMS Consent to customer address form * * @param array $params * @return array */ public function HookAdditionalCustomerAddressFields($params) { if (!isset($params['fields'])) { return []; } $hooksHandler = new HooksHandler($this); return $hooksHandler->handleAdditionalCustomerAddressFields($params); } /** * Send SMS Consent to Klaviyo * * @param array $params * @return void */ public function HookActionSubmitCustomerAddressForm($params) { $hooksHandler = new HooksHandler($this); return $hooksHandler->handleActionSubmitCustomerAddressForm($params); } /** * Send SMS Consent to Klaviyo (Place order) * * @param mixed $params * @return void */ public function hookDisplayOrderConfirmation($params) { $hooksHandler = new HooksHandler($this); return $hooksHandler->handleDisplayOrderConfirmation($params); } public function hookActionFrontControllerInitAfter($params) { if ( $this->context->controller instanceof CartController && CartRule::isFeatureActive() && Tools::getIsset('addDiscount') && $couponConfigValue = Configuration::get('KLAVIYO_COUPON_USAGE_LIMIT_TYPE') ) { $cartRuleService = $this->getService('klaviyops.prestashop_services.cart_rule'); $cart = $this->context->cart; switch ($couponConfigValue) { case KlaviyoCouponUsageLimitType::LIMIT_PREFIX: $code = trim(Tools::getValue('discount_name', '')); $prefix = $cartRuleService->getCartRuleCodePrefix($code); if ($prefix === null) { return; } $cartRule = $cartRuleService->getCartRuleByPrefix($cart, $prefix); if ($cartRule === null) { return; } $errorMsg = $this->l('This voucher is not combinable with a voucher already in your cart:', 'klaviyopsmodule'); $this->context->controller->errors[] = $errorMsg . " $cartRule->name"; case KlaviyoCouponUsageLimitType::LIMIT_ONE: $cartRules = $cart->getCartRules(CartRule::FILTER_ACTION_ALL, false); if (count($cartRules) > 0) { $this->context->controller->errors[] = $this->l('Limit of one voucher per cart.', 'klaviyopsmodule'); } } } } /** * Register klaviyo.js and identify javascript code along with customer data if * public API key is set. */ protected function setupKlaviyoAnalytics() { if (!$this->isCheckoutPage()) { $this->addIdentifyData(); $this->context->controller->registerJavascript( 'module-' . $this->name . '-analytics', 'https://static.klaviyo.com/onsite/js/' . $this->getConfigurationValueOrNull('KLAVIYO_PUBLIC_API') . '/klaviyo.js', array( 'server' => 'remote', 'priority' => 450, 'attributes' => 'async' ) ); $this->context->controller->registerJavascript( 'module-' . $this->name . '-identify', $this->getDistPathUri('identify.js'), array( 'priority' => 451, ) ); } } /** * Define global js variables of customer for identifying with klaviyo object. */ protected function addIdentifyData() { try { /** @var ContextService $contextService */ $contextService = $this->getService('klaviyops.prestashop_services.context'); /** @var CustomerService $customerService */ $customerService = $this->getService('klaviyops.prestashop_services.customer'); /** @var CustomerEventService $customerEventService */ $customerEventService = $this->getService('klaviyops.klaviyo_service.customer_event_service'); $customer = null; if (Validate::isLoadedObject($this->context->customer)) { $context = $contextService->normalize(); // Current context $customer = $customerService->normalize( $this->context->customer, $context ); $customer = $customerEventService->buildPayloadForJs($customer); } Media::addJsDef([ 'klCustomer' => $customer, ]); } catch (Exception $e) { /** @var LoggerService $logger */ $logger = $this->getService('klaviyops.prestashop_services.logger'); if (Validate::isLoadedObject($this->context->customer)) { $logger->error("An error occured in addIdentifyData for customer {$this->context->customer->email}"); } else { $logger->error('An error occured in addIdentifyData'); } } } /** * Setup Viewed Product and Add to Cart events if on Product page. * * @throws PrestaShopDatabaseException * @throws PrestaShopException */ protected function setupProductEvents() { if ($this->context->controller->php_self == 'product') { $lang_id = $this->context->language->id; $shop_id = $this->context->shop->id; $product = new Product(Tools::getValue('id_product'), $full = false, $id_lang = $lang_id, $id_shop = $shop_id); // Bail if product no longer exists. if (is_null($product->id)) { return; } $this->setupViewedProduct(); $this->setupAddToCart(); } } /** * Add viewed product data and register javascript file. * @throws PrestaShopDatabaseException * @throws PrestaShopException */ protected function setupViewedProduct() { $this->addViewedProductData(); $this->context->controller->registerJavascript( 'module-' . $this->name . '-viewed-product', $this->getDistPathUri('viewed-product.js'), array( 'priority' => 460, ) ); } /** * Define javascript global for Viewed Product event. * * @throws PrestaShopDatabaseException * @throws PrestaShopException */ protected function addViewedProductData() { $lang_id = $this->context->language->id; $shop_id = $this->context->shop->id; $product = new Product(Tools::getValue('id_product'), $full = false, $id_lang = $lang_id, $id_shop = $shop_id); $product_id = $product->id; // Build categories array $productCategories = array(); foreach (Product::getProductCategoriesFull($product_id, $lang_id) as $category) { $productCategories[] = $category['name']; }; // Get product URL and allow context to handle language. $link = new Link(); $productLink = $link->getProductLink($product); // Get product price without tax. This returns a float while // price attribute returns a string with 6 significant digits. $price = $product->getPrice(false); Media::addJsDef( array( 'klProduct' => array( 'ProductName' => $product->name, 'ProductID' => $product_id, 'SKU' => $product->reference, 'Tags' => ProductPayloadService::getProductTagsArray($product_id, $lang_id), 'Price' => KlaviyoUtils::formatPrice($price), 'PriceInclTax' => KlaviyoUtils::formatPrice($product->getPrice()), 'SpecialPrice' => KlaviyoUtils::formatPrice(Product::getPriceStatic($product_id)), 'Categories' => $productCategories, 'Image' => ProductPayloadService::buildProductImageUrls($product), 'Link' => $productLink, 'ShopID' => $shop_id, 'LangID' => $lang_id, 'eventValue' => is_numeric($price) ? $price : 0 ) ) ); } /** * Define js object to support custom base URI for added to cart module route. * @return void */ protected function addAddedToCartData() { $addedToCartUrl = rtrim(__PS_BASE_URI__, '/') . '/klaviyo/events/add-to-cart'; Media::addJsDef( [ 'klAddedToCart' => [ 'url' => $addedToCartUrl ] ] ); } /** * Register Added to Cart javascript. */ protected function setupAddToCart() { $this->addAddedToCartData(); $this->context->controller->registerJavascript( 'module-' . $this->name . '-add-to-cart', $this->getDistPathUri('add-to-cart.js'), array( 'priority' => 465, ) ); } /** * Add started checkout data and register javascript file. */ protected function setupStartedCheckout() { if ($this->shouldInsertStartedCheckoutJavascript()) { $this->addStartedCheckoutData(); $this->context->controller->registerJavascript( 'module-' . $this->name . '-started-checkout', $this->getDistPathUri('started-checkout.js'), array( 'priority' => 470, ) ); } } /** * Add javascript definition to DOM for Started Checkout events. */ protected function addStartedCheckoutData() { $link = $this->context->link; if ($link === null) { $link = new Link(); } Media::addJsDef( array( 'klStartedCheckout' => array( 'cartId' => isset(Context::getContext()->cart->id) ? Context::getContext()->cart->id : false, 'email' => $this->context->customer->email, 'token' => Tools::getToken(), 'baseUrl' => $link->getModuleLink($this->name, 'buildReclaim'), 'emailInputSelector' => $this->getCheckoutEmailInputSelector(), ) ) ); } /** * Returns custom checkout email input field selector if not default checkout. * * @return string */ protected function getCheckoutEmailInputSelector() { if ($this->isCustomCheckoutPage()) { return self::CUSTOM_CHECKOUTS_SELECTORS[$this->context->controller->page_name]; } // Default checkout page selector. return '[type="email"]'; } /** * Confirm if checkout session is on the Personal Information Step to capture email address and only send * event once. * * @param $sessionData * @return bool * @throws PrestaShopDatabaseException */ protected function isCheckoutPersonalInfoStep($sessionData) { // If a customer hasn't been to the checkout page yet, this value in the DB will be NULL. Treat as first step. if (!isset($sessionData)) { return true; } $personalInfoStep = $sessionData['checkout-personal-information-step']; return $personalInfoStep['step_is_reachable'] && !$personalInfoStep['step_is_complete']; } /** * Confirm if checkout session is on the Addresses step and the customer is Logged in. * * @param $sessionData * @return bool */ protected function isLoggedInAddressesStep($sessionData) { $addressesStep = $sessionData['checkout-addresses-step']; return $this->context->customer->isLogged() && $addressesStep['step_is_reachable'] && !$addressesStep['step_is_complete']; } /** * Confirm page default checkout or one of known custom checkouts for injecting Started Checkout code. * * @return bool */ protected function isCheckoutPage() { return $this->isDefaultCheckoutPage() || $this->isCustomCheckoutPage(); } /** * Confirm page is Order front controller and checkout page. * * @return bool */ protected function isDefaultCheckoutPage() { return $this->context->controller->php_self == 'order' && $this->context->controller->page_name == 'checkout'; } /** * Confirm page is a known custom checkout we want to support. * * @return bool */ protected function isCustomCheckoutPage() { return ( property_exists($this->context->controller, 'page_name') && in_array($this->context->controller->page_name, array_keys(self::CUSTOM_CHECKOUTS_SELECTORS)) ); } /** * Confirm this is a valid step in the checkout process for injecting Started Checkout code. * * @return bool */ protected function isCorrectCheckoutStep() { $cartId = $this->context->cart->id; if (!$cartId) { return false; } $select = 'SELECT checkout_session_data '; $from = 'FROM ' . _DB_PREFIX_ . 'cart '; $where = 'WHERE id_cart = ' . (int) $cartId; $sql = $select . $from . $where; try { $result = Db::getInstance()->ExecuteS($sql); } catch (PrestaShopDatabaseException $e) { return false; } if (!$result || !array_key_exists('checkout_session_data', $result[0])) { return false; } $sessionData = json_decode($result[0]['checkout_session_data'], true); return $this->isCheckoutPersonalInfoStep($sessionData) || $this->isLoggedInAddressesStep($sessionData); } /** * Determine if we should inject the Started Checkout javascript page type * and the given point in the checkout process. * * @return bool */ protected function shouldInsertStartedCheckoutJavascript() { return $this->isCheckoutPage() && $this->isCorrectCheckoutStep(); } }