Current File : //var/www/vinorea/modules/klaviyopsautomation/includes/KlaviyoPsModule.php |
<?php
/**
* Klaviyo
*
* NOTICE OF LICENSE
*
* This source file is subject to the Commercial License
* you can't distribute, modify or sell this code
*
* DISCLAIMER
*
* Do not edit or add to this file
* If you need help please contact extensions@klaviyo.com
*
* @author Klaviyo
* @copyright Klaviyo
* @license commercial
*/
if (!defined('_PS_VERSION_')) {
exit;
}
use KlaviyoPs\Classes\KlaviyoCouponUsageLimitType;
use KlaviyoV3Sdk\Exception\KlaviyoException;
use KlaviyoPs\Classes\HooksHandler;
use KlaviyoPs\Classes\BusinessLogicServices\ProductPayloadService;
use KlaviyoPs\Classes\KlaviyoServices\CustomerEventService;
use KlaviyoPs\Classes\KlaviyoServices\OrderEventService;
use KlaviyoPs\Classes\KlaviyoUtils;
use KlaviyoPs\Classes\KlaviyoValue;
use KlaviyoPs\Classes\PrestashopServices\ContextService;
use KlaviyoPs\Classes\PrestashopServices\CustomerService;
use KlaviyoPs\Classes\PrestashopServices\LoggerService;
use KlaviyoPs\Classes\PrestashopServices\OrderService;
use PrestaShop\ModuleLibServiceContainer\DependencyInjection\ServiceContainer;
class KlaviyoPsModule extends Module
{
/** @var string[] */
const CONFIG_KEYS = [
'KLAVIYO_PUBLIC_API',
'KLAVIYO_PRIVATE_API',
'KLAVIYO_IS_SYNCING_SUBSCRIBERS',
'KLAVIYO_SUBSCRIBER_LIST',
'KLAVIYO_ORDER_STATUS_MAP',
'KLAVIYO_TRANSACTIONAL_EMAIL_ENABLE',
'KLAVIYO_REAL_TIME_EVENT_ENABLE',
'KLAVIYO_IS_SYNCING_SMS_SUBSCRIBERS',
'KLAVIYO_SMS_SUBSCRIBER_LIST',
'KLAVIYO_SMS_SUBSCRIBE_TRIGGER',
'KLAVIYO_SMS_CONSENT_LABEL',
'KLAVIYO_SMS_CONSENT_DISCLOSURE_TEXT',
'KLAVIYO_COUPON_USAGE_LIMIT_TYPE',
'KLAVIYO_BIS_ENABLED',
];
const ADMIN_CONTROLLERS = array(
array(
'name' => '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"]',
'module-ets_onepagecheckout-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',
];
/**
* List of back in stock email templates that will not be sent
*/
const BACK_IN_STOCK_EMAIL_TEMPLATES = [
'productoutofstock',
];
/**
* @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) &&
Configuration::updateValue('KLAVIYO_BIS_ENABLED', 0) &&
$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') &&
$this->registerHook('actionObjectMailAlertAddAfter');
}
/**
* @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') &&
$this->unregisterHook('actionObjectMailAlertAddAfter');
}
/**
* 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');
}
$isNotBlockingTransactionalEmails = (
(int)Configuration::get('KLAVIYO_REAL_TIME_EVENT_ENABLE') === 0 ||
(int)Configuration::get('KLAVIYO_TRANSACTIONAL_EMAIL_ENABLE') === 0 ||
!$this->getConfigurationValueOrNull('KLAVIYO_PUBLIC_API')
);
$isBisDisabled = (int)Configuration::get('KLAVIYO_BIS_ENABLED') === 0;
// We should not block any emails based on the configuration settings.
if ($isNotBlockingTransactionalEmails && $isBisDisabled) {
return true;
}
$template = $params['template'];
$subject = $params['subject'];
$idLang = (int)$params['idLang'];
// The template corresponds to a transactional OR back in stock email we want to block.
$templatesToBlock = array_merge(self::STATIC_TRANSACTIONAL_EMAIL_TEMPLATES, self::BACK_IN_STOCK_EMAIL_TEMPLATES);
if (in_array($template, $templatesToBlock, 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');
}
}
}
}
/**
* Callback for hook that fires after MailAlert resource is created. Supports
* Back in Stock subscription in Klaviyo.
*
* @param $params array
* @return void
*/
public function hookActionObjectMailAlertAddAfter($params)
{
$hooksHandler = new HooksHandler($this);
$hooksHandler->handleActionObjectMailAlertAddAfter($params);
}
/**
* 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,
'external_catalog_id' => KlaviyoUtils::formatKlaviyoCatalogIdentifier($shop_id, $lang_id),
'integration_key' => KlaviyoValue::SERVICE,
)
)
);
}
/**
* 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();
}
}