1296 lines
53 KiB
PHP
1296 lines
53 KiB
PHP
<?php
|
||
|
||
/**
|
||
* Mautic Integration for PrestaShop
|
||
*
|
||
* @author Panariga
|
||
* @copyright 2025 Panariga
|
||
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License (AFL 3.0)
|
||
*/
|
||
|
||
if (!defined('_PS_VERSION_')) {
|
||
exit;
|
||
}
|
||
|
||
use Symfony\Component\HttpClient\HttpClient;
|
||
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
|
||
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
|
||
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
|
||
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
|
||
|
||
class MauticConnect extends Module
|
||
{
|
||
// --- STATIC CONFIGURATION ---
|
||
// Connection Settings
|
||
const MAUTIC_URL = 'MAUTICCONNECT_URL';
|
||
const MAUTIC_CLIENT_ID = 'MAUTICCONNECT_CLIENT_ID';
|
||
const MAUTIC_CLIENT_SECRET = 'MAUTICCONNECT_CLIENT_SECRET';
|
||
const MAUTIC_ACCESS_TOKEN = 'MAUTICCONNECT_ACCESS_TOKEN';
|
||
const MAUTIC_REFRESH_TOKEN = 'MAUTICCONNECT_REFRESH_TOKEN';
|
||
const MAUTIC_TOKEN_EXPIRES = 'MAUTICCONNECT_TOKEN_EXPIRES';
|
||
|
||
// --- DATA-DRIVEN EVENT DEFINITIONS ---
|
||
// To add a new event, simply add a new entry to this array.
|
||
// The module will automatically handle installation, forms, and processing.
|
||
private static $eventDefinitions = [
|
||
[
|
||
'id' => 'order_shipped',
|
||
'title' => 'Order Shipped Event',
|
||
'processor_method' => 'processOrderShippedEvent', // Generic processor for order-based events
|
||
],
|
||
[
|
||
'id' => 'order_arrived',
|
||
'title' => 'Order Arrived Event',
|
||
'processor_method' => 'processOrderArrivedEvent',
|
||
],
|
||
|
||
[
|
||
'id' => 'cart_abandon',
|
||
'title' => 'Abandon Cart Event',
|
||
'processor_method' => 'processAbandonCartEvent',
|
||
],
|
||
// Example: To add a "Refunded" event, just uncomment the next block.
|
||
/*
|
||
[
|
||
'id' => 'order_refunded',
|
||
'title' => 'Order Refunded Event',
|
||
'processor_method' => 'processOrderEvent',
|
||
],
|
||
*/
|
||
];
|
||
|
||
public function __construct()
|
||
{
|
||
$this->name = 'mauticconnect';
|
||
$this->tab = 'marketing';
|
||
$this->version = '1.2.0'; // Version incremented for new architecture
|
||
$this->author = 'Your Name';
|
||
$this->need_instance = 0;
|
||
$this->ps_versions_compliancy = ['min' => '1.7.0.0', 'max' => _PS_VERSION_];
|
||
$this->bootstrap = true;
|
||
|
||
parent::__construct();
|
||
|
||
$this->displayName = $this->l('Mautic Connect');
|
||
$this->description = $this->l('A data-driven module to integrate PrestaShop with Mautic for marketing automation.');
|
||
$this->confirmUninstall = $this->l('Are you sure you want to uninstall this module? All Mautic connection data will be lost.');
|
||
}
|
||
|
||
// --- DYNAMIC CONFIGURATION KEY HELPERS ---
|
||
private function getEventConfigKey($eventId, $type)
|
||
{
|
||
$keyMap = [
|
||
'ps_status' => 'PS_STATUS',
|
||
'mautic_segment' => 'M_SEGMENT',
|
||
'mautic_template' => 'M_TEMPLATE',
|
||
];
|
||
return 'MAUTICCONNECT_EVENT_' . strtoupper($eventId) . '_' . $keyMap[$type];
|
||
}
|
||
|
||
/**
|
||
* Module installation - now fully dynamic.
|
||
*/
|
||
public function install()
|
||
{
|
||
$sql = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'mautic_processed_hooks` (
|
||
`id_processed_hook` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
`hook_hash` VARCHAR(32) NOT NULL,
|
||
`date_add` DATETIME NOT NULL,
|
||
PRIMARY KEY (`id_processed_hook`),
|
||
UNIQUE KEY `hook_hash` (`hook_hash`)
|
||
) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8;';
|
||
Db::getInstance()->execute($sql);
|
||
Configuration::updateValue(self::MAUTIC_URL, '');
|
||
// ... set other static configs to empty/0 ...
|
||
|
||
// Dynamically install configuration for each defined event
|
||
foreach (self::$eventDefinitions as $event) {
|
||
Configuration::updateValue($this->getEventConfigKey($event['id'], 'ps_status'), 0);
|
||
Configuration::updateValue($this->getEventConfigKey($event['id'], 'mautic_segment'), 0);
|
||
Configuration::updateValue($this->getEventConfigKey($event['id'], 'mautic_template'), 0);
|
||
}
|
||
|
||
return parent::install() &&
|
||
$this->registerHook('actionCustomerAccountAdd') &&
|
||
$this->registerHook('actionObjectCustomerUpdateAfter') &&
|
||
$this->registerHook('actionOrderStatusUpdate');
|
||
}
|
||
|
||
/**
|
||
* Module uninstallation - now fully dynamic.
|
||
*/
|
||
public function uninstall()
|
||
{
|
||
// Delete static configs
|
||
$staticConfigKeys = [self::MAUTIC_URL, self::MAUTIC_CLIENT_ID];
|
||
foreach ($staticConfigKeys as $key) {
|
||
Configuration::deleteByName($key);
|
||
}
|
||
|
||
// Dynamically uninstall configuration for each defined event
|
||
foreach (self::$eventDefinitions as $event) {
|
||
Configuration::deleteByName($this->getEventConfigKey($event['id'], 'ps_status'));
|
||
Configuration::deleteByName($this->getEventConfigKey($event['id'], 'mautic_segment'));
|
||
Configuration::deleteByName($this->getEventConfigKey($event['id'], 'mautic_template'));
|
||
}
|
||
|
||
Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'mautic_processed_hooks`');
|
||
|
||
return parent::uninstall();
|
||
}
|
||
|
||
/**
|
||
* Renders the configuration page and handles all logic.
|
||
*/
|
||
public function getContent()
|
||
{
|
||
$output = '';
|
||
$mauticUrl = Tools::getValue(self::MAUTIC_URL);
|
||
$clientId = Tools::getValue(self::MAUTIC_CLIENT_ID);
|
||
$clientSecret = Tools::getValue(self::MAUTIC_CLIENT_SECRET);
|
||
if (Tools::isSubmit('submit' . $this->name)) {
|
||
|
||
$output .= $this->postProcess();
|
||
}
|
||
|
||
|
||
$output .= $this->displayConnectionStatus();
|
||
$output .= $this->renderForms(); // Single method to render all forms
|
||
|
||
return $output;
|
||
}
|
||
|
||
/**
|
||
* Processes all form submissions dynamically.
|
||
*/
|
||
private function postProcess()
|
||
{
|
||
// Save connection settings
|
||
Configuration::updateValue(self::MAUTIC_URL, rtrim(Tools::getValue(self::MAUTIC_URL, ''), '/'));
|
||
Configuration::updateValue(self::MAUTIC_CLIENT_ID, Tools::getValue(self::MAUTIC_CLIENT_ID, ''));
|
||
Configuration::updateValue(self::MAUTIC_CLIENT_SECRET, Tools::getValue(self::MAUTIC_CLIENT_SECRET, ''));
|
||
// Handle disconnect request
|
||
if (Tools::isSubmit('disconnectMautic')) {
|
||
Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, '');
|
||
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, '');
|
||
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0);
|
||
}
|
||
$mauticUrl = Tools::getValue(self::MAUTIC_URL);
|
||
$clientId = Tools::getValue(self::MAUTIC_CLIENT_ID);
|
||
$clientSecret = Tools::getValue(self::MAUTIC_CLIENT_SECRET);
|
||
if ($mauticUrl && $clientId && $clientSecret) {
|
||
Configuration::updateValue(self::MAUTIC_URL, rtrim($mauticUrl, '/'));
|
||
Configuration::updateValue(self::MAUTIC_CLIENT_ID, $clientId);
|
||
Configuration::updateValue(self::MAUTIC_CLIENT_SECRET, $clientSecret);
|
||
} else {
|
||
return $this->displayError($this->l('Mautic URL, Client ID, and Client Secret are required.'));
|
||
}
|
||
// Dynamically save event mapping settings
|
||
if ($this->isConnected()) {
|
||
foreach (self::$eventDefinitions as $event) {
|
||
Configuration::updateValue($this->getEventConfigKey($event['id'], 'ps_status'), (int)Tools::getValue($this->getEventConfigKey($event['id'], 'ps_status')));
|
||
Configuration::updateValue($this->getEventConfigKey($event['id'], 'mautic_segment'), (int)Tools::getValue($this->getEventConfigKey($event['id'], 'mautic_segment')));
|
||
Configuration::updateValue($this->getEventConfigKey($event['id'], 'mautic_template'), (int)Tools::getValue($this->getEventConfigKey($event['id'], 'mautic_template')));
|
||
}
|
||
}
|
||
|
||
return $this->displayConfirmation($this->l('Settings saved.'));
|
||
}
|
||
|
||
/**
|
||
* Generates all configuration forms dynamically.
|
||
*/
|
||
public function renderForms()
|
||
{
|
||
$helper = new HelperForm();
|
||
$helper->module = $this;
|
||
$helper->name_controller = $this->name;
|
||
$helper->token = Tools::getAdminTokenLite('AdminModules');
|
||
$helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name;
|
||
$helper->submit_action = 'submit' . $this->name;
|
||
$helper->default_form_language = (int)Configuration::get('PS_LANG_DEFAULT');
|
||
|
||
|
||
// --- Form 1: Connection Settings (no change) ---
|
||
|
||
// ... (loading values for form 1) ...
|
||
$helper->fields_value[self::MAUTIC_URL] = Configuration::get(self::MAUTIC_URL);
|
||
$helper->fields_value[self::MAUTIC_CLIENT_ID] = Configuration::get(self::MAUTIC_CLIENT_ID);
|
||
$helper->fields_value[self::MAUTIC_CLIENT_SECRET] = Configuration::get(self::MAUTIC_CLIENT_SECRET);
|
||
|
||
$fields_form[0]['form'] = [
|
||
'legend' => [
|
||
'title' => $this->l('Mautic API Settings'),
|
||
'icon' => 'icon-cogs',
|
||
],
|
||
'input' => [
|
||
[
|
||
'type' => 'text',
|
||
'label' => $this->l('Mautic Base URL'),
|
||
'name' => self::MAUTIC_URL,
|
||
'required' => true,
|
||
'desc' => $this->l('e.g., https://your-mautic-instance.com'),
|
||
],
|
||
[
|
||
'type' => 'text',
|
||
'label' => $this->l('Client ID'),
|
||
'name' => self::MAUTIC_CLIENT_ID,
|
||
'required' => true,
|
||
],
|
||
[
|
||
'type' => 'text',
|
||
'label' => $this->l('Client Secret'),
|
||
'name' => self::MAUTIC_CLIENT_SECRET,
|
||
'required' => true,
|
||
],
|
||
],
|
||
'submit' => [
|
||
'title' => $this->l('Save'),
|
||
'class' => 'btn btn-default pull-right',
|
||
],
|
||
];
|
||
// --- Form 2: Event Mappings (only if connected) ---
|
||
if ($this->isConnected()) {
|
||
try {
|
||
$mauticSegments = $this->getMauticSegments();
|
||
// We fetch the TRANSACTIONAL emails here
|
||
$mauticTransactionalEmails = $this->getMauticEmails();
|
||
$prestashopStatuses = $this->getPrestaShopStatuses();
|
||
} catch (Exception $e) {
|
||
return $this->displayError($this->l('Could not fetch data from Mautic to build the form. Error: ') . $e->getMessage());
|
||
}
|
||
|
||
$event_inputs = [];
|
||
foreach (self::$eventDefinitions as $event) {
|
||
$psStatusKey = $this->getEventConfigKey($event['id'], 'ps_status');
|
||
$segmentKey = $this->getEventConfigKey($event['id'], 'mautic_segment');
|
||
$templateKey = $this->getEventConfigKey($event['id'], 'mautic_template');
|
||
|
||
if (!empty($event_inputs)) {
|
||
$event_inputs[] = ['type' => 'html', 'name' => 'html_data', 'html_content' => '<hr>'];
|
||
}
|
||
$event_inputs[] = ['type' => 'html', 'name' => 'html_data', 'html_content' => '<h4>' . $this->l($event['title']) . '</h4>'];
|
||
|
||
$event_inputs[] = ['type' => 'select', 'label' => $this->l('PrestaShop Status (Trigger)'), 'name' => $psStatusKey, 'options' => ['query' => $prestashopStatuses, 'id' => 'id', 'name' => 'name']];
|
||
$event_inputs[] = ['type' => 'select', 'label' => $this->l('Mautic Segment (Target)'), 'name' => $segmentKey, 'options' => ['query' => $mauticSegments, 'id' => 'id', 'name' => 'name']];
|
||
|
||
// *** CHANGE IS HERE ***
|
||
// We now clearly label the field and add a description for the user.
|
||
$event_inputs[] = [
|
||
'type' => 'select',
|
||
'label' => $this->l('Mautic Transactional Email'),
|
||
'name' => $templateKey,
|
||
'desc' => $this->l('Only "Template Emails" are shown. These are emails not tied to campaigns, suitable for transactional messages.'),
|
||
'options' => [
|
||
'query' => $mauticTransactionalEmails, // Use the correctly named variable
|
||
'id' => 'id',
|
||
'name' => 'name'
|
||
]
|
||
];
|
||
// *** END OF CHANGE ***
|
||
|
||
$helper->fields_value[$psStatusKey] = Configuration::get($psStatusKey);
|
||
$helper->fields_value[$segmentKey] = Configuration::get($segmentKey);
|
||
$helper->fields_value[$templateKey] = Configuration::get($templateKey);
|
||
}
|
||
|
||
$fields_form[1]['form'] = [
|
||
'legend' => ['title' => $this->l('3. Event Mapping'), 'icon' => 'icon-random'],
|
||
'input' => $event_inputs,
|
||
|
||
'submit' => [
|
||
'title' => $this->l('Save'),
|
||
'class' => 'btn btn-default pull-right',
|
||
],
|
||
];
|
||
}
|
||
|
||
return $helper->generateForm($fields_form);
|
||
}
|
||
|
||
|
||
/**
|
||
* Hook that triggers on order status updates. Now fully dynamic.
|
||
*/
|
||
public function hookActionOrderStatusUpdate($params)
|
||
{
|
||
if (!$this->isConnected()) {
|
||
return false;
|
||
}
|
||
$orderId = (int)$params['id_order'];
|
||
$newStatusId = (int)$params['newOrderStatus']->id;
|
||
$this->log(['orderId' => $orderId, 'newStatusId' => $newStatusId]);
|
||
$eventHash = md5($newStatusId . '_' . $orderId);
|
||
if ($this->isAlreadyProcessed($eventHash)) {
|
||
return;
|
||
}
|
||
|
||
// Loop through our defined events to see if any match the new status
|
||
foreach (self::$eventDefinitions as $event) {
|
||
$configuredStatusId = (int)Configuration::get($this->getEventConfigKey($event['id'], 'ps_status'));
|
||
|
||
// If the new status matches the one configured for this event...
|
||
if ($configuredStatusId > 0 && $newStatusId === $configuredStatusId) {
|
||
// ...call the processor method defined for this event.
|
||
if (method_exists($this, $event['processor_method'])) {
|
||
$this->{$event['processor_method']}($orderId, $event);
|
||
|
||
$this->markAsProcessed($eventHash);
|
||
|
||
// We break because an order status change should only trigger one event.
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
public function runAbandonCartCampaign()
|
||
{
|
||
$cartCollection = new PrestaShopCollection('Cart');
|
||
$cartCollection->where('id_customer', '!=', 0);
|
||
$cartCollection->where('date_add', '>', date('Y-m-d', time() - 60 * 60 * 24 * 1));
|
||
$cartCollection->where('date_add', '<', date('Y-m-d'));
|
||
//@var Cart $cart
|
||
foreach ($cartCollection as $cart) {
|
||
|
||
if (!Order::getIdByCartId($cart->id)) {
|
||
$this->processAbandonCart($cart->id);
|
||
}
|
||
}
|
||
}
|
||
public function processAbandonCart(int $id_cart)
|
||
{
|
||
if (!$this->isConnected()) {
|
||
return false;
|
||
}
|
||
|
||
$eventHash = md5('abandon_cart' . '_' . $id_cart);
|
||
if ($this->isAlreadyProcessed($eventHash)) {
|
||
return;
|
||
}
|
||
|
||
|
||
// Loop through our defined events to see if any match the new status
|
||
foreach (self::$eventDefinitions as $event) {
|
||
if ($event['id'] === 'cart_abandon') {
|
||
// ...call the processor method defined for this event.
|
||
if (method_exists($this, $event['processor_method'])) {
|
||
$this->{$event['processor_method']}($id_cart, $event);
|
||
$this->markAsProcessed($eventHash);
|
||
// We break because an order status change should only trigger one event.
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ... displayConnectionStatus, getMauticAuthUrl, getOauth2RedirectUri ...
|
||
// ... makeApiRequest, refreshTokenIfNeeded ...
|
||
// ... hookActionCustomerAccountAdd, hookActionObjectCustomerUpdateAfter, syncCustomer ...
|
||
// ... sendOrderEmail, findContactByEmail, and all other helpers remain mostly unchanged ...
|
||
|
||
// --- DATA PROVIDERS FOR FORMS ---
|
||
|
||
private function getPrestaShopStatuses(): array
|
||
{
|
||
$statuses = OrderState::getOrderStates((int)$this->context->language->id);
|
||
$options = [['id' => 0, 'name' => $this->l('--- Disabled ---')]];
|
||
foreach ($statuses as $status) {
|
||
$options[] = ['id' => $status['id_order_state'], 'name' => $status['name']];
|
||
}
|
||
return $options;
|
||
}
|
||
|
||
public function getMauticSegments(): array
|
||
{
|
||
$response = $this->makeApiRequest('/api/segments');
|
||
$segments = $response['lists'] ?? [];
|
||
$options = [['id' => 0, 'name' => $this->l('--- Please Select ---')]];
|
||
foreach ($segments as $segment) {
|
||
$options[] = ['id' => $segment['id'], 'name' => $segment['name']];
|
||
}
|
||
return $options;
|
||
}
|
||
|
||
private function getMauticEmails(): array
|
||
{
|
||
$response = $this->makeApiRequest('/api/emails');
|
||
$emails = $response['emails'] ?? [];
|
||
$options = [['id' => 0, 'name' => $this->l('--- Please Select ---')]];
|
||
|
||
foreach ($emails as $email) {
|
||
// We MUST filter for 'template' type emails. These are the Mautic equivalent
|
||
// of transactional emails, designed to be sent to a single contact via API.
|
||
// 'list' emails are for mass-mailing to segments and are not suitable here.
|
||
if (isset($email['emailType']) && $email['emailType'] === 'template') {
|
||
$options[] = ['id' => $email['id'], 'name' => $email['name']];
|
||
}
|
||
}
|
||
return $options;
|
||
}
|
||
|
||
|
||
/**
|
||
* Generates the configuration form.
|
||
*/
|
||
public function renderForm()
|
||
{
|
||
$helper = new HelperForm();
|
||
$helper->module = $this;
|
||
$helper->name_controller = $this->name;
|
||
$helper->token = Tools::getAdminTokenLite('AdminModules');
|
||
$helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name;
|
||
$helper->submit_action = 'submit' . $this->name;
|
||
$helper->default_form_language = (int)Configuration::get('PS_LANG_DEFAULT');
|
||
|
||
$helper->fields_value[self::MAUTIC_URL] = Configuration::get(self::MAUTIC_URL);
|
||
$helper->fields_value[self::MAUTIC_CLIENT_ID] = Configuration::get(self::MAUTIC_CLIENT_ID);
|
||
$helper->fields_value[self::MAUTIC_CLIENT_SECRET] = Configuration::get(self::MAUTIC_CLIENT_SECRET);
|
||
|
||
$fields_form[0]['form'] = [
|
||
'legend' => [
|
||
'title' => $this->l('Mautic API Settings'),
|
||
'icon' => 'icon-cogs',
|
||
],
|
||
'input' => [
|
||
[
|
||
'type' => 'text',
|
||
'label' => $this->l('Mautic Base URL'),
|
||
'name' => self::MAUTIC_URL,
|
||
'required' => true,
|
||
'desc' => $this->l('e.g., https://your-mautic-instance.com'),
|
||
],
|
||
[
|
||
'type' => 'text',
|
||
'label' => $this->l('Client ID'),
|
||
'name' => self::MAUTIC_CLIENT_ID,
|
||
'required' => true,
|
||
],
|
||
[
|
||
'type' => 'text',
|
||
'label' => $this->l('Client Secret'),
|
||
'name' => self::MAUTIC_CLIENT_SECRET,
|
||
'required' => true,
|
||
],
|
||
],
|
||
'submit' => [
|
||
'title' => $this->l('Save'),
|
||
'class' => 'btn btn-default pull-right',
|
||
],
|
||
];
|
||
|
||
return $helper->generateForm($fields_form);
|
||
}
|
||
|
||
/**
|
||
* Displays the current connection status and connect/disconnect buttons.
|
||
*/
|
||
public function displayConnectionStatus()
|
||
{
|
||
$isConfigured = Configuration::get(self::MAUTIC_URL) &&
|
||
Configuration::get(self::MAUTIC_CLIENT_ID) &&
|
||
Configuration::get(self::MAUTIC_CLIENT_SECRET);
|
||
|
||
$isConnected = (bool)Configuration::get(self::MAUTIC_ACCESS_TOKEN);
|
||
|
||
$this->context->smarty->assign([
|
||
'is_configured' => $isConfigured,
|
||
'is_connected' => $isConnected,
|
||
'mautic_auth_url' => $isConfigured ? $this->getMauticAuthUrl() : '#',
|
||
'disconnect_url' => AdminController::$currentIndex . '&configure=' . $this->name . '&disconnectMautic=1&token=' . Tools::getAdminTokenLite('AdminModules'),
|
||
]);
|
||
|
||
// We will create this tpl file in the future if needed, for now, we build HTML here.
|
||
$html = '<div class="panel">';
|
||
$html .= '<h3><i class="icon-link"></i> ' . $this->l('Connection Status') . '</h3>';
|
||
|
||
if ($isConnected) {
|
||
$html .= '<div class="alert alert-success">' . $this->l('Successfully connected to Mautic.') . '</div>';
|
||
$html .= '<a href="' . AdminController::$currentIndex . '&configure=' . $this->name . '&disconnectMautic=1&token=' . Tools::getAdminTokenLite('AdminModules') . '" class="btn btn-danger">' . $this->l('Disconnect from Mautic') . '</a>';
|
||
} else {
|
||
$html .= '<div class="alert alert-warning">' . $this->l('Not connected to Mautic.') . '</div>';
|
||
if ($isConfigured) {
|
||
$html .= '<a href="' . $isConfigured ? $this->getMauticAuthUrl() : '#' . '" class="btn btn-primary">' . $this->l('Connect to Mautic') . '</a>';
|
||
} else {
|
||
$html .= '<p>' . $this->l('Please fill in and save your API settings above before connecting.') . '</p>';
|
||
}
|
||
}
|
||
$html .= '</div>';
|
||
return $html;
|
||
}
|
||
|
||
/**
|
||
* Builds the Mautic authorization URL.
|
||
*/
|
||
public function getMauticAuthUrl()
|
||
{
|
||
$mauticUrl = Configuration::get(self::MAUTIC_URL);
|
||
$params = [
|
||
'client_id' => Configuration::get(self::MAUTIC_CLIENT_ID),
|
||
'grant_type' => 'authorization_code',
|
||
'redirect_uri' => $this->getOauth2RedirectUri(),
|
||
'response_type' => 'code',
|
||
'state' => 'optional_csrf_token_' . bin2hex(random_bytes(16)) // For security
|
||
];
|
||
|
||
return $mauticUrl . '/oauth/v2/authorize?' . http_build_query($params);
|
||
}
|
||
|
||
/**
|
||
* Gets the OAuth2 callback URL for this module.
|
||
*/
|
||
public function getOauth2RedirectUri()
|
||
{
|
||
|
||
return $this->context->link->getModuleLink($this->name, 'oauth2', [], true);
|
||
}
|
||
|
||
/* Core function to make any request to the Mautic API using Symfony HTTP Client.
|
||
* It handles token refreshing, authentication headers, and error checking.
|
||
*
|
||
* @param string $endpoint The API endpoint (e.g., '/api/contacts').
|
||
* @param string $method The HTTP method (GET, POST, PATCH).
|
||
* @param array $data The data to send with POST/PATCH requests.
|
||
* @return array The decoded JSON response.
|
||
* @throws Exception On HTTP or API errors.
|
||
*/
|
||
private function makeApiRequest($endpoint, $method = 'GET', $data = [])
|
||
{
|
||
$this->refreshTokenIfNeeded();
|
||
|
||
$accessToken = Configuration::get(self::MAUTIC_ACCESS_TOKEN);
|
||
$mauticUrl = Configuration::get(self::MAUTIC_URL);
|
||
|
||
/** @var \Symfony\Contracts\HttpClient\HttpClientInterface $client */
|
||
$client = HttpClient::create();
|
||
|
||
$options = [
|
||
'headers' => [
|
||
'Authorization' => 'Bearer ' . $accessToken,
|
||
'Accept' => 'application/json',
|
||
],
|
||
];
|
||
|
||
// For POST/PATCH requests, send the data as a JSON payload.
|
||
if (in_array($method, ['POST', 'PATCH', 'PUT']) && !empty($data)) {
|
||
$options['json'] = $data;
|
||
}
|
||
|
||
try {
|
||
$response = $client->request($method, $mauticUrl . $endpoint, $options);
|
||
|
||
// This will throw an exception for 4xx and 5xx status codes.
|
||
$responseData = $response->toArray();
|
||
|
||
// Mautic can still return a 200 OK with an error payload
|
||
if (isset($responseData['errors'])) {
|
||
$errorMessage = $responseData['errors'][0]['message'] ?? 'Unknown Mautic API Error';
|
||
throw new Exception('Mautic API Error: ' . $errorMessage);
|
||
}
|
||
|
||
return $responseData;
|
||
} catch (TransportExceptionInterface $e) {
|
||
// Errors related to the transport (e.g., DNS, connection timeout)
|
||
throw new Exception('Mautic Connection Error: ' . $e->getMessage(), 0, $e);
|
||
} catch (ClientExceptionInterface | ServerExceptionInterface | RedirectionExceptionInterface $e) {
|
||
// Errors for 3xx, 4xx, 5xx responses
|
||
$errorMessage = $e->getResponse()->getContent(false); // Get body without throwing another exception
|
||
throw new Exception('Mautic API Error (' . $e->getCode() . '): ' . $errorMessage, $e->getCode(), $e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Checks if the access token is expired and uses the refresh token to get a new one.
|
||
*
|
||
* @throws Exception if refreshing the token fails.
|
||
*/
|
||
private function refreshTokenIfNeeded()
|
||
{
|
||
$expiresAt = (int)Configuration::get(self::MAUTIC_TOKEN_EXPIRES);
|
||
|
||
if (time() < $expiresAt) {
|
||
return; // Token is still valid
|
||
}
|
||
|
||
$mauticUrl = Configuration::get(self::MAUTIC_URL);
|
||
$refreshToken = Configuration::get(self::MAUTIC_REFRESH_TOKEN);
|
||
|
||
if (!$refreshToken) {
|
||
throw new Exception('Cannot refresh token: Refresh token is missing.');
|
||
}
|
||
|
||
$client = HttpClient::create();
|
||
|
||
$postData = [
|
||
'client_id' => Configuration::get(self::MAUTIC_CLIENT_ID),
|
||
'client_secret' => Configuration::get(self::MAUTIC_CLIENT_SECRET),
|
||
'grant_type' => 'refresh_token',
|
||
'refresh_token' => $refreshToken,
|
||
];
|
||
|
||
try {
|
||
$response = $client->request('POST', $mauticUrl . '/oauth/v2/token', [
|
||
// Symfony client correctly encodes this as application/x-www-form-urlencoded
|
||
'body' => $postData,
|
||
]);
|
||
|
||
$data = $response->toArray();
|
||
|
||
if (!isset($data['access_token'])) {
|
||
throw new Exception('Mautic response did not contain an access_token.');
|
||
}
|
||
|
||
// Success! Save the new tokens and expiry time.
|
||
Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, $data['access_token']);
|
||
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, $data['refresh_token']);
|
||
$expiresAt = time() + (int)$data['expires_in'] - 60; // 60s buffer
|
||
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, $expiresAt);
|
||
} catch (Exception $e) {
|
||
// Critical failure: we can no longer authenticate.
|
||
Configuration::updateValue(self::MAUTIC_ACCESS_TOKEN, '');
|
||
Configuration::updateValue(self::MAUTIC_REFRESH_TOKEN, '');
|
||
Configuration::updateValue(self::MAUTIC_TOKEN_EXPIRES, 0);
|
||
throw new Exception('Failed to refresh Mautic token. Please reconnect the module. Error: ' . $e->getMessage(), 0, $e);
|
||
}
|
||
}
|
||
|
||
|
||
// --- HOOKS FOR CUSTOMER SYNC ---
|
||
|
||
/**
|
||
* Hook called when a new customer account is created.
|
||
*/
|
||
public function hookActionCustomerAccountAdd($params)
|
||
{
|
||
if (!$this->isConnected()) {
|
||
return false;
|
||
}
|
||
$this->log(['newCustomer' => $params['newCustomer']]);
|
||
|
||
if (isset($params['newCustomer']) && Validate::isLoadedObject($params['newCustomer'])) {
|
||
|
||
$this->syncCustomer($params['newCustomer']);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hook called after a customer object is updated.
|
||
*/
|
||
public function hookActionObjectCustomerUpdateAfter($params)
|
||
{
|
||
if (!$this->isConnected()) {
|
||
return false;
|
||
}
|
||
if (isset($params['object']) && $params['object'] instanceof Customer) {
|
||
$this->syncCustomer($params['object']);
|
||
}
|
||
}
|
||
/**
|
||
* Simple check to see if the module is fully configured and connected.
|
||
*/
|
||
public function isConnected()
|
||
{
|
||
return (bool)Configuration::get(self::MAUTIC_ACCESS_TOKEN);
|
||
}
|
||
public function syncAllCustomers(bool $full = false)
|
||
{
|
||
$customers = new PrestaShopCollection('Customer');
|
||
if (!$full) {
|
||
$customers->where('date_add', '>', date("Y-m-d H:i:s", time() - 60 - 60 * 24 * 2));
|
||
}
|
||
foreach ($customers as $customer) {
|
||
$this->syncCustomer($customer);
|
||
}
|
||
}
|
||
/**
|
||
* Synchronizes a PrestaShop customer with Mautic.
|
||
* Checks if the contact exists, then updates or creates it.
|
||
*
|
||
* @param Customer $customer The PrestaShop customer object.
|
||
* @return bool True on success, false on failure.
|
||
*/
|
||
public function syncCustomer(Customer $customer)
|
||
{
|
||
if (!$this->isConnected() || !Validate::isLoadedObject($customer) || strpos($customer->email, '@' . Tools::getShopDomainSsl()) || $customer->email == 'anonymous@psgdpr.com') {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// Find contact in Mautic by email
|
||
$mauticContact = $this->findContactByEmail($customer->email);
|
||
|
||
$customerData = [
|
||
'firstname' => $customer->firstname,
|
||
'lastname' => $customer->lastname,
|
||
'email' => $customer->email,
|
||
];
|
||
|
||
if ($mauticContact) {
|
||
// Contact exists, update it
|
||
$this->updateMauticContact($mauticContact['id'], $customerData);
|
||
} else {
|
||
// Contact does not exist, create it
|
||
$this->createMauticContact($customerData);
|
||
}
|
||
} catch (Exception $e) {
|
||
// Log the error for debugging without breaking the user's experience
|
||
PrestaShopLogger::addLog(
|
||
'MauticConnect Error: ' . $e->getMessage(),
|
||
3, // Severity: 3 for Error
|
||
null,
|
||
'MauticConnect',
|
||
null,
|
||
true
|
||
);
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
|
||
// --- MAUTIC API HELPER FUNCTIONS ---
|
||
|
||
/**
|
||
* Searches for a contact in Mautic by their email address.
|
||
*
|
||
* @param string $email
|
||
* @return array|null The contact data if found, otherwise null.
|
||
*/
|
||
private function findContactByEmail($email)
|
||
{
|
||
$endpoint = '/api/contacts?search=email:' . urlencode($email);
|
||
$response = $this->makeApiRequest($endpoint, 'GET');
|
||
|
||
// If contacts are found, Mautic returns them in a 'contacts' array.
|
||
if (!empty($response['contacts'])) {
|
||
// Return the first match
|
||
return reset($response['contacts']);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Creates a new contact in Mautic.
|
||
*
|
||
* @param array $data Contact data (firstname, lastname, email).
|
||
* @return array The API response.
|
||
*/
|
||
private function createMauticContact($data)
|
||
{
|
||
return $this->makeApiRequest('/api/contacts/new', 'POST', $data);
|
||
}
|
||
|
||
/**
|
||
* Updates an existing contact in Mautic.
|
||
*
|
||
* @param int $contactId The Mautic contact ID.
|
||
* @param array $data Contact data to update.
|
||
* @return array The API response.
|
||
*/
|
||
private function updateMauticContact($contactId, $data)
|
||
{
|
||
// PATCH is used for partial updates, which is more efficient.
|
||
return $this->makeApiRequest('/api/contacts/' . (int)$contactId . '/edit', 'PATCH', $data);
|
||
}
|
||
|
||
/**
|
||
* Checks if a contact with a given email is a member of a specific Mautic segment.
|
||
* This version uses the dedicated /contacts/{id}/segments endpoint.
|
||
*
|
||
* @param string $email The email address of the contact to check.
|
||
* @param int $segmentId The ID of the Mautic segment.
|
||
* @return bool True if the contact is in the segment, false otherwise.
|
||
*/
|
||
public function isContactInSegment($email, $segmentId)
|
||
{
|
||
// --- 1. Guard Clauses: Validate input and connection status ---
|
||
if (!$this->isConnected()) {
|
||
PrestaShopLogger::addLog('MauticConnect: Cannot check segment; module is not connected.', 2);
|
||
return false;
|
||
}
|
||
|
||
if (empty($email) || !Validate::isEmail($email) || empty($segmentId) || !Validate::isUnsignedId($segmentId)) {
|
||
PrestaShopLogger::addLog('MauticConnect: Invalid email or segment ID provided for segment check.', 2);
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// --- 2. Step 1: Find the contact by email to get their Mautic ID ---
|
||
$contact = $this->findContactByEmail($email);
|
||
|
||
if (!$contact || !isset($contact['id'])) {
|
||
// Contact doesn't exist in Mautic, so they can't be in the segment.
|
||
return false;
|
||
}
|
||
|
||
// --- 3. Step 2: Use the dedicated endpoint to get only their segments ---
|
||
$segments = $this->getContactSegments($contact['id']);
|
||
|
||
// --- 4. Check if the target segment ID is in the list ---
|
||
if (!empty($segments)) {
|
||
// array_column creates an array of just the 'id' values from the segments
|
||
$segmentIds = array_column($segments, 'id');
|
||
return in_array($segmentId, $segmentIds);
|
||
}
|
||
|
||
// The contact exists but is in no segments.
|
||
return false;
|
||
} catch (Exception $e) {
|
||
// Log the error for debugging but return false to avoid breaking site functionality.
|
||
PrestaShopLogger::addLog(
|
||
'MauticConnect: Error checking segment membership for ' . $email . ': ' . $e->getMessage(),
|
||
3 // Severity 3 for Error
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* Gets a list of segments a specific contact is a member of.
|
||
* Implements the GET /api/contacts/{id}/segments endpoint.
|
||
*
|
||
* @param int $contactId The Mautic contact ID.
|
||
* @return array A list of segment arrays, or an empty array.
|
||
* @throws Exception
|
||
*/
|
||
private function getContactSegments(int $contactId)
|
||
{
|
||
|
||
$response = $this->makeApiRequest("/api/contacts/$contactId/segments", 'GET');
|
||
// The response for this endpoint has a top-level key 'segments'
|
||
// which contains an array of the segment objects.
|
||
return $response['lists'] ?? [];
|
||
}
|
||
public function processOrderArrivedEvent(int $id_order, array $eventDefinition)
|
||
{
|
||
|
||
$mauticSegmentId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_segment'));
|
||
$mauticTemplateId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_template'));
|
||
|
||
// Do nothing if this event is not fully configured
|
||
if (!$mauticSegmentId || !$mauticTemplateId) {
|
||
return;
|
||
}
|
||
|
||
// 2. Get all necessary objects
|
||
$order = new Order($id_order);
|
||
$customer = new Customer((int)$order->id_customer);
|
||
$currency = new Currency((int)$order->id_currency);
|
||
$carrier = new Carrier((int)$order->id_carrier);
|
||
$link = new Link(); // Needed for generating image URLs
|
||
$this->syncCustomer($customer);
|
||
// 3. Gather primary data
|
||
$customer_email = $customer->email;
|
||
if (!$this->isContactInSegment($customer_email, $mauticSegmentId)) {
|
||
return;
|
||
}
|
||
$tracking_number = $order->getWsShippingNumber();
|
||
if (empty($tracking_number)) {
|
||
return; // Can't send a shipping email without a tracking number
|
||
}
|
||
|
||
// Replace with your actual carrier's tracking URL format
|
||
$tracking_url = str_replace('@', $tracking_number, $carrier->url);
|
||
$order_number = $order->reference;
|
||
$order_date = date('Y-m-d', strtotime($order->date_add)); // Format as YYYY-MM-DD
|
||
|
||
// 4. Build the dynamic Product HTML and JSON
|
||
$products_html = '<table width="100%" cellpadding="10" cellspacing="0" style="border-collapse: collapse; margin-top: 20px;">';
|
||
$order_items_for_json = [];
|
||
|
||
$products = $order->getProducts();
|
||
foreach ($products as $product) {
|
||
$product_obj = new Product($product['product_id'], false, $this->context->language->id);
|
||
$image_url = $link->getImageLink($product_obj->link_rewrite, $product['image']->id, 'cart_default');
|
||
|
||
// --- Build the HTML part ---
|
||
$products_html .= '<tr style="border-bottom: 1px solid #eee;">
|
||
<td width="80"><img src="https://' . $image_url . '" alt="' . $product['product_name'] . '" width="70" style="border: 1px solid #ddd;"></td>
|
||
<td><a href="' . $product_obj->getLink() . '" >' . $product['product_name'] . '</a><br><small>' . $product['product_quantity'] . ' x ' . round($product['unit_price_tax_incl'], 2) . ' ' . $currency->iso_code . '</small></td>
|
||
<td align="right">' . round($product['total_price_tax_incl'], 2) . ' ' . $currency->iso_code . '</td>
|
||
</tr>';
|
||
|
||
// --- Build the PHP array for the JSON part ---
|
||
$order_items_for_json[] = [
|
||
"@type" => "Offer",
|
||
"itemOffered" => [
|
||
"@type" => "Product",
|
||
"name" => $product['product_name'],
|
||
"sku" => $product['product_reference'],
|
||
"gtin" => $product['product_ean13'],
|
||
"image" => 'https://' . $image_url
|
||
],
|
||
"price" => round($product['unit_price_tax_incl'], 2),
|
||
"priceCurrency" => $currency->iso_code
|
||
];
|
||
}
|
||
$products_html .= '</table>';
|
||
$ldData = [
|
||
"@context" => "http://schema.org",
|
||
"@type" => "Order",
|
||
"merchant" => [
|
||
"@type" => "Organization",
|
||
"name" => Tools::getShopDomainSsl()
|
||
],
|
||
"orderNumber" => $order_number,
|
||
"orderStatus" => "http://schema.org/OrderInTransit",
|
||
"orderDate" => date('Y-m-d H:i:sP', strtotime($order->date_add)),
|
||
"trackingUrl" => $tracking_url,
|
||
"acceptedOffer" => $order_items_for_json
|
||
];
|
||
// Convert the PHP array to a clean JSON string. IMPORTANT: remove the outer [] brackets for Mautic.
|
||
$order_items_json_string = '<script type="application/ld+json">' . json_encode($ldData) . '</script>';
|
||
|
||
// 5. Prepare the final payload for the Mautic API
|
||
$data_for_mautic = [
|
||
'tracking_url' => $tracking_url,
|
||
'tracking_number' => $tracking_number,
|
||
'last_order_number' => $order_number,
|
||
'last_order_date' => $order_date,
|
||
'order_products_html' => $products_html,
|
||
'order_items_json' => $order_items_json_string,
|
||
'firstname' => $customer->firstname
|
||
];
|
||
$mauticContactId = $this->getMauticContactIdByEmail($customer_email);
|
||
|
||
$endpointUrl = implode('', [
|
||
'/api/emails/',
|
||
$mauticTemplateId,
|
||
'/contact/',
|
||
$mauticContactId,
|
||
'/send'
|
||
]);
|
||
$response = $this->makeApiRequest($endpointUrl, 'POST', ['tokens' => $data_for_mautic]);
|
||
return $response;
|
||
}
|
||
|
||
|
||
public function processAbandonCartEvent(int $id_cart, array $eventDefinition)
|
||
{
|
||
|
||
$mauticSegmentId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_segment'));
|
||
$mauticTemplateId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_template'));
|
||
$smarty = new Smarty();
|
||
// Do nothing if this event is not fully configured
|
||
if (!$mauticSegmentId || !$mauticTemplateId) {
|
||
return;
|
||
}
|
||
// 2. Get all necessary objects
|
||
$cart = new Cart($id_cart);
|
||
if (!$cart->id_customer) {
|
||
return;
|
||
}
|
||
$customer = new Customer((int)$cart->id_customer);
|
||
|
||
$currency = new Currency((int)$cart->id_currency);
|
||
|
||
Context::getContext()->currency = $currency;
|
||
Context::getContext()->customer = $customer;
|
||
Context::getContext()->language = new Language($cart->id_lang);
|
||
Context::getContext()->country = new \Country(\Configuration::get('PS_COUNTRY_DEFAULT'));
|
||
Context::getContext()->link = new Link();
|
||
// 3. Gather primary data
|
||
$customer_email = $customer->email;
|
||
if (!$this->isContactInSegment($customer_email, $mauticSegmentId)) {
|
||
return;
|
||
}
|
||
|
||
|
||
$action_url = Context::getContext()->link->getPageLink('cart', true, null, [
|
||
'action' => 'show',
|
||
|
||
]);
|
||
$products = $cart->getProducts();
|
||
if (!count($products)) {
|
||
return;
|
||
}
|
||
$abandoned_cart_items_for_json = [];
|
||
$abandoned_cart_items_for_html = [];
|
||
foreach ($products as $product) {
|
||
|
||
$product_obj = new Product($product['id_product'], false, Context::getContext()->language->id);
|
||
$product_url = $product_obj->getLink();
|
||
$cover_img = Product::getCover($product_obj->id);
|
||
$image_url = Context::getContext()->link->getImageLink($product_obj->link_rewrite, $cover_img['id_image'], 'cart_default');
|
||
|
||
$abandoned_cart_items_for_html[] = [
|
||
'image_url' => $image_url,
|
||
'product_name' => $product['name'],
|
||
'product_quantity' => $product['cart_quantity'],
|
||
'product_url' => $product_url,
|
||
'unit_price_tax_incl' => round($product['price_with_reduction'], 2),
|
||
'total_price_tax_incl' => round($product['price_with_reduction'] * $product['cart_quantity'], 2),
|
||
|
||
'currency_iso_code' => $currency->iso_code,
|
||
];
|
||
$abandoned_cart_items_for_json[] = [
|
||
"@type" => "Offer",
|
||
"itemOffered" => [
|
||
"@type" => "Product",
|
||
"name" => $product['name'],
|
||
"sku" => $product['reference'],
|
||
// Only include 'gtin' if it's consistently available and a valid EAN/UPC/ISBN
|
||
"gtin" => $product['ean13'],
|
||
"image" => 'https://' . $image_url, // Ensure this is a full, valid URL
|
||
"url" => $product_url // Link directly to the product page
|
||
],
|
||
"price" => round($product['price_with_reduction'], 2),
|
||
"priceCurrency" => $currency->iso_code,
|
||
"itemCondition" => "http://schema.org/NewCondition"
|
||
];
|
||
}
|
||
|
||
$ldData = [
|
||
"@context" => "http://schema.org",
|
||
"@type" => "EmailMessage", // This is an email about an abandoned cart
|
||
"potentialAction" => [
|
||
"@type" => "ReserveAction", // Or "BuyAction" if it's a direct purchase flow, "ViewAction" if just to see cart.
|
||
"name" => "Завершіть Замовлення",
|
||
"target" => [
|
||
"@type" => "EntryPoint",
|
||
"urlTemplate" => $action_url, // The dynamic URL to complete the order
|
||
"actionPlatform" => [
|
||
"http://schema.org/DesktopWebPlatform",
|
||
"http://schema.org/MobileWebPlatform"
|
||
]
|
||
]
|
||
],
|
||
"about" => [ // What this email is about: the abandoned cart items
|
||
"@type" => "OfferCatalog", // A collection of offers/products
|
||
"name" => "Неоформлене замовлення",
|
||
"description" => "Ви, можливо, забули придбати ці товари на " . Tools::getShopDomainSsl(),
|
||
// Optionally, add a general image for the catalog/brand
|
||
// // "image": "https://exclusion-ua.shop/logo.png",
|
||
"merchant" => [
|
||
"@type" => "Organization",
|
||
"name" => Tools::getShopDomainSsl(),
|
||
"url" => Tools::getShopDomainSsl(true) // URL of your store
|
||
],
|
||
"itemListElement" => $abandoned_cart_items_for_json // The list of products
|
||
]
|
||
];
|
||
|
||
// Convert the PHP array to a clean JSON string.
|
||
// Use JSON_UNESCAPED_SLASHES for clean URLs and JSON_PRE
|
||
|
||
$abandoned_cart_json_string = '<script type="application/ld+json">' . json_encode($ldData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . '</script>';
|
||
|
||
// 5. Prepare the final payload for the Mautic API
|
||
$smarty->assign([
|
||
'products' => $abandoned_cart_items_for_html,
|
||
'json_ld_data' => $ldData,
|
||
|
||
]);
|
||
$data_for_mautic = [
|
||
'action_url' => $action_url,
|
||
'html_data' => $smarty->fetch($this->local_path . 'views/templates/mail/product_list_table.tpl'),
|
||
'json_ld_data' => $smarty->fetch($this->local_path . 'views/templates/mail/json_ld_data.tpl'),
|
||
];
|
||
$mauticContactId = $this->getMauticContactIdByEmail($customer_email);
|
||
|
||
$endpointUrl = implode('', [
|
||
'/api/emails/',
|
||
$mauticTemplateId,
|
||
'/contact/',
|
||
$mauticContactId,
|
||
'/send'
|
||
]);
|
||
$response = $this->makeApiRequest($endpointUrl, 'POST', ['tokens' => $data_for_mautic]);
|
||
return $response;
|
||
}
|
||
|
||
|
||
public function processOrderShippedEvent(int $id_order, array $eventDefinition)
|
||
{
|
||
$mauticSegmentId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_segment'));
|
||
$mauticTemplateId = (int)Configuration::get($this->getEventConfigKey($eventDefinition['id'], 'mautic_template'));
|
||
|
||
// Do nothing if this event is not fully configured
|
||
if (!$mauticSegmentId || !$mauticTemplateId) {
|
||
return;
|
||
}
|
||
|
||
// 2. Get all necessary objects
|
||
$order = new Order($id_order);
|
||
$customer = new Customer((int)$order->id_customer);
|
||
$currency = new Currency((int)$order->id_currency);
|
||
$carrier = new Carrier((int)$order->id_carrier);
|
||
$link = new Link(); // Needed for generating image URLs
|
||
$this->syncCustomer($customer);
|
||
// 3. Gather primary data
|
||
$customer_email = $customer->email;
|
||
if (!$this->isContactInSegment($customer_email, $mauticSegmentId)) {
|
||
return;
|
||
}
|
||
$tracking_number = $order->getWsShippingNumber();
|
||
if (empty($tracking_number)) {
|
||
return; // Can't send a shipping email without a tracking number
|
||
}
|
||
|
||
// Replace with your actual carrier's tracking URL format
|
||
$tracking_url = str_replace('@', $tracking_number, $carrier->url);
|
||
$order_number = $order->reference;
|
||
$order_date = date('Y-m-d', strtotime($order->date_add)); // Format as YYYY-MM-DD
|
||
|
||
// 4. Build the dynamic Product HTML and JSON
|
||
$products_html = '<table width="100%" cellpadding="10" cellspacing="0" style="border-collapse: collapse; margin-top: 20px;">';
|
||
$order_items_for_json = [];
|
||
|
||
$products = $order->getProducts();
|
||
foreach ($products as $product) {
|
||
$product_obj = new Product($product['product_id'], false, $this->context->language->id);
|
||
$image_url = $link->getImageLink($product_obj->link_rewrite, $product['image']->id, 'cart_default');
|
||
// --- Build the HTML part ---
|
||
$products_html .= '<tr style="border-bottom: 1px solid #eee;">
|
||
<td width="80"><img src="https://' . $image_url . '" alt="' . $product['product_name'] . '" width="70" style="border: 1px solid #ddd;"></td>
|
||
<td><a href="' . $product_obj->getLink() . '" >' . $product['product_name'] . '</a><br><small>' . $product['product_quantity'] . ' x ' . round($product['unit_price_tax_incl'], 2) . ' ' . $currency->iso_code . '</small></td>
|
||
<td align="right">' . round($product['total_price_tax_incl'], 2) . ' ' . $currency->iso_code . '</td>
|
||
</tr>';
|
||
|
||
// --- Build the PHP array for the JSON part ---
|
||
$order_items_for_json[] = [
|
||
"@type" => "Offer",
|
||
"itemOffered" => [
|
||
"@type" => "Product",
|
||
"name" => $product['product_name'],
|
||
"sku" => $product['product_reference'],
|
||
"gtin" => $product['product_ean13'],
|
||
"image" => 'https://' . $image_url
|
||
],
|
||
"price" => round($product['unit_price_tax_incl'], 2),
|
||
"priceCurrency" => $currency->iso_code
|
||
];
|
||
}
|
||
$products_html .= '</table>';
|
||
$ldData = [
|
||
"@context" => "http://schema.org",
|
||
"@type" => "Order",
|
||
"merchant" => [
|
||
"@type" => "Organization",
|
||
"name" => Tools::getShopDomainSsl()
|
||
],
|
||
"orderNumber" => $order_number,
|
||
"orderStatus" => "http://schema.org/OrderInTransit",
|
||
"orderDate" => date('Y-m-d H:i:sP', strtotime($order->date_add)),
|
||
"trackingUrl" => $tracking_url,
|
||
"acceptedOffer" => $order_items_for_json
|
||
];
|
||
// Convert the PHP array to a clean JSON string. IMPORTANT: remove the outer [] brackets for Mautic.
|
||
$order_items_json_string = '<script type="application/ld+json">' . json_encode($ldData) . '</script>';
|
||
|
||
// 5. Prepare the final payload for the Mautic API
|
||
$data_for_mautic = [
|
||
'tracking_url' => $tracking_url,
|
||
'tracking_number' => $tracking_number,
|
||
'last_order_number' => $order_number,
|
||
'last_order_date' => $order_date,
|
||
'order_products_html' => $products_html,
|
||
'order_items_json' => $order_items_json_string,
|
||
'firstname' => $customer->firstname
|
||
];
|
||
$mauticContactId = $this->getMauticContactIdByEmail($customer_email);
|
||
$endpointUrl = implode('', [
|
||
'/api/emails/',
|
||
$mauticTemplateId,
|
||
'/contact/',
|
||
$mauticContactId,
|
||
'/send'
|
||
]);
|
||
$response = $this->makeApiRequest($endpointUrl, 'POST', ['tokens' => $data_for_mautic]);
|
||
return $response;
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* Finds a Mautic contact by their email address and returns their Mautic ID.
|
||
*
|
||
* This function uses the Mautic API's search functionality.
|
||
* It's designed to be efficient by specifically requesting only the necessary data.
|
||
*
|
||
* @param string $email The email address of the contact to find.
|
||
* @return int|null The Mautic contact ID if found, otherwise null.
|
||
* @throws Exception If there is an API communication error.
|
||
*/
|
||
private function getMauticContactIdByEmail(string $email): int
|
||
{
|
||
// 1. Basic validation to prevent unnecessary API calls for invalid emails.
|
||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||
// Or throw new \InvalidArgumentException('Invalid email format provided.');
|
||
return 0;
|
||
}
|
||
|
||
// 2. Construct the API endpoint with a search filter.
|
||
// The format `email:{value}` is Mautic's specific search syntax.
|
||
// We must urlencode the email to handle special characters like '+'.
|
||
$endpoint = '/api/contacts?search=email:' . urlencode($email);
|
||
|
||
// 3. Use your existing helper function to make the GET request.
|
||
// We don't need to pass a method (defaults to 'GET') or data.
|
||
$responseData = $this->makeApiRequest($endpoint);
|
||
|
||
// 4. Process the response to extract the ID.
|
||
// Mautic returns contacts as an associative array where the KEY is the contact ID.
|
||
// We check if the 'contacts' key exists and is not empty.
|
||
if (!empty($responseData['contacts']) && is_array($responseData['contacts'])) {
|
||
// Get all the keys (which are the contact IDs) from the associative array.
|
||
$contactIds = array_keys($responseData['contacts']);
|
||
|
||
// Return the first ID found, cast to an integer.
|
||
return (int)$contactIds[0];
|
||
}
|
||
|
||
// 5. If the 'contacts' array is empty or doesn't exist, the contact was not found.
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* Checks the database to see if a hook event has already been logged.
|
||
*
|
||
* @param string $hash A unique md5 hash representing the event.
|
||
* @return bool True if the event has been processed, false otherwise.
|
||
*/
|
||
private function isAlreadyProcessed(string $hash): bool
|
||
{
|
||
$db = Db::getInstance();
|
||
$sql = 'SELECT `id_processed_hook`
|
||
FROM `' . _DB_PREFIX_ . 'mautic_processed_hooks`
|
||
WHERE `hook_hash` = "' . pSQL($hash) . '"';
|
||
|
||
$result = $db->getValue($sql);
|
||
|
||
return (bool)$result;
|
||
}
|
||
|
||
/**
|
||
* Logs a processed hook event in the database to prevent duplicates.
|
||
*
|
||
* @param string $hash A unique md5 hash representing the event.
|
||
* @return void
|
||
*/
|
||
private function markAsProcessed(string $hash): void
|
||
{
|
||
$db = Db::getInstance();
|
||
$db->insert('mautic_processed_hooks', [
|
||
'hook_hash' => pSQL($hash),
|
||
'date_add' => date('Y-m-d H:i:s'),
|
||
], false, true, Db::INSERT_IGNORE); // INSERT IGNORE is a safe way to prevent errors on race conditions
|
||
}
|
||
|
||
public function log(array $data)
|
||
{
|
||
|
||
$data = json_encode($data, JSON_UNESCAPED_UNICODE);
|
||
|
||
$logdirectory = _PS_ROOT_DIR_ . '/var/modules/' . $this->name . '/logs/' . date("Y") . '/' . date("m") . '/' . date("d") . '/';
|
||
if (!is_dir($logdirectory)) {
|
||
mkdir($logdirectory, 0750, true);
|
||
}
|
||
$logger = new \FileLogger(0); //0 == debug level, logDebug() won’t work without this.
|
||
$logger->setFilename($logdirectory . 'dayly.log');
|
||
$logger->logInfo($data);
|
||
}
|
||
}
|