working version

This commit is contained in:
dimitar 2025-01-30 02:58:14 +01:00
parent 184cac66e6
commit 7e0aa41be6
11 changed files with 875 additions and 0 deletions

BIN
ecomzone-v3.1.zip Normal file

Binary file not shown.

BIN
ecomzone-v5.1.zip Normal file

Binary file not shown.

BIN
ecomzone-v5.zip Normal file

Binary file not shown.

View File

@ -0,0 +1,107 @@
<?php
class EcomZoneClient
{
private $apiToken;
private $apiUrl;
public function __construct()
{
// Hardcoded API token for testing
$this->apiToken = 'klRyAdrXaxL0s6PEUp7LDlH6T8aPSCtBY8NiEHsHiWpc6646K2TZPi5KMxUg';
$this->apiUrl = Configuration::get('ECOMZONE_API_URL');
if (empty($this->apiUrl)) {
throw new Exception('API URL not configured');
}
}
public function getCatalog($page = 1, $perPage = 100)
{
$url = $this->apiUrl . '/catalog?page=' . $page . '&per_page=' . $perPage;
EcomZoneLogger::log("Making API request", 'INFO', ['url' => $url]);
return $this->makeRequest('GET', $url);
}
public function getProduct($sku)
{
return $this->makeRequest('GET', $this->apiUrl . '/product/' . $sku);
}
public function createOrder($orderData)
{
return $this->makeRequest('POST', $this->apiUrl . '/ordering', $orderData);
}
public function getOrder($orderId)
{
return $this->makeRequest('GET', $this->apiUrl . '/order/' . $orderId);
}
private function makeRequest($method, $url, $data = null)
{
$retryCount = 0;
$maxRetries = 5;
$backoff = 1; // initial backoff time in seconds
do {
$curl = curl_init();
$headers = [
'Authorization: Bearer ' . $this->apiToken,
'Accept: application/json',
'Content-Type: application/json',
];
$options = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_SSL_VERIFYPEER => false, // Temporarily disable SSL verification for testing
];
if ($data !== null) {
$options[CURLOPT_POSTFIELDS] = json_encode($data);
}
curl_setopt_array($curl, $options);
$response = curl_exec($curl);
$err = curl_error($curl);
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
EcomZoneLogger::log("API Response", 'INFO', [
'url' => $url,
'method' => $method,
'http_code' => $httpCode,
'curl_error' => $err,
'response' => $response
]);
curl_close($curl);
if ($err) {
if (strpos($err, 'Connection timed out') !== false && $retryCount < $maxRetries) {
EcomZoneLogger::log("Connection timed out. Retrying in $backoff seconds...", 'WARNING');
sleep($backoff);
$backoff *= 2; // exponential backoff
$retryCount++;
} else {
throw new Exception('cURL Error: ' . $err);
}
} else if ($httpCode >= 400) {
throw new Exception('API Error: HTTP ' . $httpCode . ' - ' . $response);
} else {
$decodedResponse = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Invalid JSON response: ' . json_last_error_msg());
}
return $decodedResponse;
}
} while ($retryCount < $maxRetries);
}
}

View File

@ -0,0 +1,40 @@
<?php
class EcomZoneCronTask extends ModuleGrid
{
public function __construct()
{
parent::__construct();
$this->name = 'ecomzone_cron';
$this->title = $this->l('EcomZone Product Sync');
$this->cron_frequency = 3600 * 2; // Run every hour
}
public function install()
{
if (!parent::install()) {
return false;
}
Configuration::updateValue('ECOMZONE_LAST_CRON_RUN', '');
return true;
}
public function run()
{
try {
EcomZoneLogger::log('Starting scheduled product sync');
$productSync = new EcomZoneProductSync();
$result = $productSync->importProducts();
Configuration::updateValue('ECOMZONE_LAST_CRON_RUN', date('Y-m-d H:i:s'));
EcomZoneLogger::log('Scheduled product sync completed', 'INFO', $result);
return true;
} catch (Exception $e) {
EcomZoneLogger::log('Scheduled product sync failed', 'ERROR', ['error' => $e->getMessage()]);
return false;
}
}
}

View File

@ -0,0 +1,25 @@
<?php
class EcomZoneLogger
{
const LOG_FILE = _PS_ROOT_DIR_ . '/var/logs/ecomzone.log';
public static function log($message, $level = 'INFO', $context = [])
{
$date = date('Y-m-d H:i:s');
$contextStr = !empty($context) ? json_encode($context) : '';
$logMessage = "[$date] [$level] $message $contextStr\n";
file_put_contents(self::LOG_FILE, $logMessage, FILE_APPEND);
// Also log to PrestaShop
PrestaShopLogger::addLog(
"EcomZone: $message",
($level === 'ERROR' ? 3 : 1),
null,
'EcomZone',
null,
true
);
}
}

View File

@ -0,0 +1,81 @@
<?php
class EcomZoneOrderSync
{
private $client;
public function __construct()
{
$this->client = new EcomZoneClient();
}
public function syncOrder($orderId)
{
try {
EcomZoneLogger::log("Starting order sync", 'INFO', ['order_id' => $orderId]);
$order = new Order($orderId);
$customer = new Customer($order->id_customer);
$address = new Address($order->id_address_delivery);
$orderData = $this->prepareOrderData($order, $customer, $address);
$result = $this->client->createOrder($orderData);
EcomZoneLogger::log("Order sync completed", 'INFO', [
'order_id' => $orderId,
'result' => $result
]);
return $result;
} catch (Exception $e) {
EcomZoneLogger::log("Order sync failed", 'ERROR', [
'order_id' => $orderId,
'error' => $e->getMessage()
]);
throw $e;
}
}
private function prepareOrderData($order, $customer, $address)
{
return [
'order_index' => $order->id,
'ext_id' => $order->reference,
'payment' => [
'method' => $this->getPaymentMethod($order),
'customer_price' => $order->total_paid
],
'customer_data' => [
'full_name' => $customer->firstname . ' ' . $customer->lastname,
'email' => $customer->email,
'phone_number' => $address->phone,
'country' => Country::getIsoById($address->id_country),
'address' => $address->address1 . ' ' . $address->address2,
'city' => $address->city,
'post_code' => $address->postcode
],
'items' => $this->getOrderItems($order)
];
}
private function getPaymentMethod($order)
{
// Map PrestaShop payment modules to eComZone payment methods
return $order->module === 'cashondelivery' ? 'cod' : 'pp';
}
private function getOrderItems($order)
{
$items = [];
$products = $order->getProducts();
foreach ($products as $product) {
$items[] = [
'full_sku' => $product['reference'],
'quantity' => $product['product_quantity']
];
}
return $items;
}
}

View File

@ -0,0 +1,270 @@
<?php
class EcomZoneProductSync
{
private $client;
public function __construct()
{
$this->client = new EcomZoneClient();
}
private function resizeImage($src, $dest, $width, $height)
{
list($srcWidth, $srcHeight, $type) = getimagesize($src);
$srcImage = $this->createImageFromType($src, $type);
$destImage = imagecreatetruecolor($width, $height);
imagecopyresampled($destImage, $srcImage, 0, 0, 0, 0, $width, $height, $srcWidth, $srcHeight);
$this->saveImageFromType($destImage, $dest, $type);
imagedestroy($srcImage);
imagedestroy($destImage);
}
private function createImageFromType($filename, $type)
{
switch ($type) {
case IMAGETYPE_JPEG:
return imagecreatefromjpeg($filename);
case IMAGETYPE_PNG:
return imagecreatefrompng($filename);
case IMAGETYPE_GIF:
return imagecreatefromgif($filename);
default:
throw new Exception("Unsupported image type: " . $type);
}
}
private function saveImageFromType($image, $filename, $type)
{
switch ($type) {
case IMAGETYPE_JPEG:
$this->createDirectoryIfNotExists(dirname($filename));
imagejpeg($image, $filename);
break;
case IMAGETYPE_PNG:
$this->createDirectoryIfNotExists(dirname($filename));
imagepng($image, $filename);
break;
case IMAGETYPE_GIF:
$this->createDirectoryIfNotExists(dirname($filename));
imagegif($image, $filename);
break;
case IMAGETYPE_GIF:
imagegif($image, $filename);
break;
default:
throw new Exception("Unsupported image type: " . $type);
}
}
public function importProducts($perPage = 100)
{
$page = 1;
$totalImported = 0;
$totalAvailable = 0;
EcomZoneLogger::log("Starting product import - perPage: $perPage");
EcomZoneLogger::log("Number of products to be imported per page: $perPage", 'INFO');
try {
do {
$catalog = $this->client->getCatalog($page, $perPage);
$importedCount = 0;
foreach ($catalog['data'] as $product) {
if ($this->importSingleProduct($product)) {
$importedCount++;
}
}
$totalImported += $importedCount;
$totalAvailable = $catalog['total'];
$page++;
EcomZoneLogger::log("Imported page $page", 'INFO', [
'total_imported' => $totalImported,
'page' => $page,
'total_available' => $totalAvailable
]);
} while ($page <= ceil($totalAvailable / $perPage) && isset($catalog['next_page_url']) && $catalog['next_page_url'] !== null);
EcomZoneLogger::log("Finished importing products", 'INFO', [
'total_imported' => $totalImported,
'total_available' => $totalAvailable
]);
// Clear cache
Tools::clearSmartyCache();
Tools::clearXMLCache();
Media::clearCache();
PrestaShopAutoload::getInstance()->generateIndex();
return [
'success' => true,
'imported' => $totalImported,
'total' => $totalAvailable
];
} catch (Exception $e) {
EcomZoneLogger::log("Error importing products: " . $e->getMessage(), 'ERROR');
throw $e;
}
}
private function importSingleProduct($productData)
{
// Extract data from nested structure
$data = isset($productData['data']) ? $productData['data'] : $productData;
if (!isset($data['sku']) || !isset($data['product_name']) || !isset($data['description']) || !isset($data['product_price'])) {
EcomZoneLogger::log("Invalid product data", 'ERROR', ['data' => $productData]);
return false;
}
try {
// Check if product already exists by reference
$productId = Db::getInstance()->getValue('
SELECT id_product
FROM ' . _DB_PREFIX_ . 'product
WHERE reference = "' . pSQL($data['sku']) . '"
');
$product = $productId ? new Product($productId) : new Product();
$defaultLangId = (int)Configuration::get('PS_LANG_DEFAULT');
$product->reference = $data['sku'];
$product->name[$defaultLangId] = $data['product_name'];
$product->description[$defaultLangId] = $data['long_description'] ?? $data['description'];
$product->description_short[$defaultLangId] = $data['description'];
$product->price = $data['product_price'];
$product->active = true;
$product->quantity = (int)$data['stock'];
$homeCategoryId = (int)Configuration::get('PS_HOME_CATEGORY');
$product->id_category_default = $homeCategoryId;
$product->addToCategories([$homeCategoryId]);
$product->visibility = 'both'; // Ensure product is visible in both catalog and search
$product->active = true;
$product->quantity = (int)$data['stock'];
$homeCategoryId = (int)Configuration::get('PS_HOME_CATEGORY');
$product->id_category_default = $homeCategoryId;
$product->addToCategories([$homeCategoryId]);
// Save product first to get ID
if (!$product->id) {
$product->add();
} else {
$product->update();
}
StockAvailable::setQuantity($product->id, 0, (int)$data['stock']);
$product->available_for_order = true; // Ensure product is available for order
$product->show_price = true; // Ensure price is shown
// Handle image import if URL is provided
if (isset($data['image']) && !empty($data['image'])) {
$this->importProductImage($product, $data['image']);
}
StockAvailable::setQuantity($product->id, 0, (int)$data['stock']);
EcomZoneLogger::log("Imported product", 'INFO', [
'sku' => $data['sku'],
'id' => $product->id,
'name' => $data['product_name']
]);
return true;
} catch (Exception $e) {
EcomZoneLogger::log("Error importing product", 'ERROR', [
'sku' => $data['sku'],
'error' => $e->getMessage()
]);
return false;
}
}
private function importProductImage($product, $imageUrl)
{
try {
// Create temporary file
$tmpFile = tempnam(_PS_TMP_IMG_DIR_, 'ecomzone_');
// Download image
if (!copy($imageUrl, $tmpFile)) {
throw new Exception("Failed to download image from: " . $imageUrl);
}
// Get image info
$imageInfo = getimagesize($tmpFile);
if (!$imageInfo) {
unlink($tmpFile);
throw new Exception("Invalid image file");
}
// Validate image dimensions and file size
if ($imageInfo[0] > 2000 || $imageInfo[1] > 2000 || filesize($tmpFile) > 5000000) {
unlink($tmpFile);
throw new Exception("Image dimensions or file size exceed limits");
}
// Generate unique name
$imageName = $product->reference . '-' . time() . '.' . pathinfo($imageUrl, PATHINFO_EXTENSION);
// Delete existing images if any
$product->deleteImages();
// Add new image
$image = new Image();
$image->id_product = $product->id;
$image->position = 1;
$image->cover = true;
// Save the image to the correct directory
$imagePath = _PS_PROD_IMG_DIR_ . $image->getImgPath() . '.' . $image->image_format;
$this->createDirectoryIfNotExists(dirname($imagePath));
if (!copy($tmpFile, $imagePath)) {
unlink($tmpFile);
throw new Exception("Failed to save image to: " . $imagePath);
}
// Associate the image with the product
if (!$image->add()) {
unlink($tmpFile);
throw new Exception("Failed to add image to product");
}
// Manually resize the image and generate thumbnails
$this->resizeImage($imagePath, _PS_PROD_IMG_DIR_ . $image->getImgPath() . '-home_default.' . $image->image_format, 250, 250);
$this->resizeImage($imagePath, _PS_PROD_IMG_DIR_ . $image->getImgPath() . '-large_default.' . $image->image_format, 800, 800);
// Cleanup
unlink($tmpFile);
EcomZoneLogger::log("Imported product image", 'INFO', [
'sku' => $product->reference,
'image' => $imageUrl
]);
} catch (Exception $e) {
EcomZoneLogger::log("Error importing product image", 'ERROR', [
'sku' => $product->reference,
'image' => $imageUrl,
'error' => $e->getMessage()
]);
}
}
private function createDirectoryIfNotExists($directory)
{
if (!is_dir($directory)) {
mkdir($directory, 0755, true);
}
}
}

43
ecomzone/cron.php Normal file
View File

@ -0,0 +1,43 @@
<?php
include(dirname(__FILE__) . '/../../config/config.inc.php');
include(dirname(__FILE__) . '/../../init.php');
// Include the module class
require_once dirname(__FILE__) . '/ecomzone.php';
// Ensure module is loaded
$module = Module::getInstanceByName('ecomzone');
if (!$module) {
die('Module not found');
}
// Security token check
$token = null;
if (php_sapi_name() === 'cli') {
global $argv;
foreach ($argv as $arg) {
if (preg_match('/^--token=(.+)$/', $arg, $matches)) {
$token = $matches[1];
break;
}
}
} else {
$token = Tools::getValue('token');
}
$configToken = Configuration::get('ECOMZONE_CRON_TOKEN');
echo "Config token: " . $configToken . "\n";
echo "Provided token: " . $token . "\n";
if (empty($token) || $token !== $configToken) {
die('Invalid token. Provided: ' . $token . ', Expected: ' . $configToken);
}
try {
$result = $module->runCronTasks();
echo json_encode(['success' => true, 'result' => $result]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

219
ecomzone/ecomzone.php Normal file
View File

@ -0,0 +1,219 @@
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
class EcomZone extends Module
{
public function __construct()
{
$this->name = 'ecomzone';
$this->tab = 'market_place';
$this->version = '1.0.0';
$this->author = 'Your Name';
$this->need_instance = 0;
$this->bootstrap = true;
parent::__construct();
// Register autoloader
spl_autoload_register([$this, 'autoload']);
$this->displayName = $this->l('EcomZone Dropshipping');
$this->description = $this->l('Integration with EcomZone Dropshipping API');
}
/**
* Autoload classes
*/
public function autoload($className)
{
// Only handle our module's classes
if (strpos($className, 'EcomZone') !== 0) {
return;
}
$classPath = dirname(__FILE__) . '/classes/' . $className . '.php';
if (file_exists($classPath)) {
require_once $classPath;
}
}
public function install()
{
return parent::install() &&
$this->registerHook('actionOrderStatusUpdate') &&
Configuration::updateValue('ECOMZONE_API_TOKEN', '') &&
Configuration::updateValue('ECOMZONE_API_URL', 'https://dropship.ecomzone.eu/api') &&
Configuration::updateValue('ECOMZONE_LAST_SYNC', '') &&
Configuration::updateValue('ECOMZONE_CRON_TOKEN', Tools::encrypt(uniqid()));
}
private function createLogFile()
{
if (!file_exists(dirname(EcomZoneLogger::LOG_FILE))) {
mkdir(dirname(EcomZoneLogger::LOG_FILE), 0755, true);
}
if (!file_exists(EcomZoneLogger::LOG_FILE)) {
touch(EcomZoneLogger::LOG_FILE);
chmod(EcomZoneLogger::LOG_FILE, 0666);
}
return true;
}
public function uninstall()
{
return parent::uninstall() &&
Configuration::deleteByName('ECOMZONE_API_TOKEN') &&
Configuration::deleteByName('ECOMZONE_API_URL') &&
Configuration::deleteByName('ECOMZONE_CRON_TOKEN') &&
Configuration::deleteByName('ECOMZONE_LAST_CRON_RUN');
}
public function getContent()
{
$output = '';
if (Tools::isSubmit('submitEcomZoneModule')) {
Configuration::updateValue('ECOMZONE_API_TOKEN', Tools::getValue('ECOMZONE_API_TOKEN'));
$output .= $this->displayConfirmation($this->l('Settings updated'));
}
// Handle manual product import
if (Tools::isSubmit('importP<Down>oducts')) {
try {
$productSync = new EcomZoneProductSync();
$result = $productSync->importProducts();
$output .= $this->displayConfirmation(
sprintf($this->l('Imported %d products'), $result['imported'])
);
} catch (Exception $e) {
$output .= $this->displayError($e->getMessage());
}
}
// Add debug info
$debugInfo = $this->getDebugInfo();
$shopUrl = Tools::getShopDomainSsl(true);
$shopRoot = _PS_ROOT_DIR_;
$this->context->smarty->assign([
'ECOMZONE_API_TOKEN' => Configuration::get('ECOMZONE_API_TOKEN'),
'ECOMZONE_CRON_TOKEN' => Configuration::get('ECOMZONE_CRON_TOKEN'),
'ECOMZONE_DEBUG_INFO' => $debugInfo,
'ECOMZONE_LOGS' => $this->getRecentLogs(),
'shop_url' => $shopUrl,
'shop_root' => $shopRoot
]);
return $output . $this->display(__FILE__, 'views/templates/admin/configure.tpl');
}
private function getDebugInfo()
{
$lastCronRun = Configuration::get('ECOMZONE_LAST_CRON_RUN');
$nextRun = !empty($lastCronRun) ?
date('Y-m-d H:i:s', strtotime($lastCronRun) + 3600) :
$this->l('Not scheduled yet');
return [
'php_version' => PHP_VERSION,
'prestashop_version' => _PS_VERSION_,
'module_version' => $this->version,
'curl_enabled' => function_exists('curl_version'),
'api_url' => Configuration::get('ECOMZONE_API_URL'),
'last_sync' => Configuration::get('ECOMZONE_LAST_SYNC'),
'last_cron_run' => $lastCronRun ?: $this->l('Never'),
'next_cron_run' => $nextRun,
'log_file' => EcomZoneLogger::LOG_FILE,
'log_file_exists' => file_exists(EcomZoneLogger::LOG_FILE),
'log_file_writable' => is_writable(EcomZoneLogger::LOG_FILE)
];
}
private function getRecentLogs($lines = 50)
{
if (!file_exists(EcomZoneLogger::LOG_FILE)) {
return [];
}
$logs = array_slice(file(EcomZoneLogger::LOG_FILE), -$lines);
return array_map('trim', $logs);
}
public function hookActionOrderStatusUpdate($params)
{
$order = $params['order'];
$newOrderStatus = $params['newOrderStatus'];
// Sync order when it's paid
if ($newOrderStatus->paid == 1) {
try {
$orderSync = new EcomZoneOrderSync();
$result = $orderSync->syncOrder($order->id);
// Log the result
PrestaShopLogger::addLog(
'EcomZone order sync: ' . json_encode($result),
1,
null,
'Order',
$order->id,
true
);
} catch (Exception $e) {
PrestaShopLogger::addLog(
'EcomZone order sync error: ' . $e->getMessage(),
3,
null,
'Order',
$order->id,
true
);
}
}
}
public function hookActionCronJob($params)
{
$cronTask = new EcomZoneCronTask();
// Check if it's time to run
$lastRun = Configuration::get('ECOMZONE_LAST_CRON_RUN');
if (empty($lastRun) || (strtotime($lastRun) + $cronTask->cron_frequency) <= time()) {
return $cronTask->run();
}
return true;
}
public function runCronTasks()
{
try {
EcomZoneLogger::log('Starting scheduled product sync');
$lastRun = Configuration::get('ECOMZONE_LAST_CRON_RUN');
$frequency = 3600; // 1 hour in seconds
if (!empty($lastRun) && (strtotime($lastRun) + $frequency) > time()) {
EcomZoneLogger::log('Skipping cron - too soon since last run');
return false;
}
$productSync = new EcomZoneProductSync();
$result = $productSync->importProducts();
Configuration::updateValue('ECOMZONE_LAST_CRON_RUN', date('Y-m-d H:i:s'));
EcomZoneLogger::log('Scheduled product sync completed', 'INFO', $result);
return $result;
} catch (Exception $e) {
EcomZoneLogger::log('Scheduled product sync failed', 'ERROR', ['error' => $e->getMessage()]);
throw $e;
}
}
}

View File

@ -0,0 +1,90 @@
<form method="post" action="{$current|escape:'html':'UTF-8'}&token={$token|escape:'html':'UTF-8'}">
<div class="panel">
<div class="panel-heading">
<i class="icon-cogs"></i> {l s='EcomZone Configuration' mod='ecomzone'}
</div>
<div class="form-wrapper">
<div class="form-group">
<label class="control-label col-lg-3">{l s='API Token' mod='ecomzone'}</label>
<div class="col-lg-9">
<input type="text" name="ECOMZONE_API_TOKEN" value="{$ECOMZONE_API_TOKEN}" />
</div>
</div>
</div>
<div class="panel-footer">
<button type="submit" name="submitEcomZoneModule" class="btn btn-default pull-right">
<i class="process-icon-save"></i> {l s='Save' mod='ecomzone'}
</button>
</div>
</div>
</form>
<div class="panel">
<div class="panel-heading">
<i class="icon-cogs"></i> {l s='Manual Actions' mod='ecomzone'}
</div>
<div class="form-wrapper">
<form method="post" class="form-horizontal">
<div class="form-group">
<div class="col-lg-9 col-lg-offset-3">
<button type="submit" name="importProducts" class="btn btn-default">
<i class="process-icon-download"></i> {l s='Import Products' mod='ecomzone'}
</button>
</div>
</div>
</form>
</div>
</div>
<div class="panel">
<div class="panel-heading">
<i class="icon-clock"></i> {l s='Cron Setup Instructions' mod='ecomzone'}
</div>
<div class="form-wrapper">
<p>{l s='Add the following command to your server crontab to run every hour:' mod='ecomzone'}</p>
<pre>0 * * * * curl -s "{$shop_url}modules/ecomzone/cron.php?token={$ECOMZONE_CRON_TOKEN}"</pre>
<p>{l s='Or using PHP CLI:' mod='ecomzone'}</p>
<pre>0 * * * * php {$shop_root}modules/ecomzone/cron.php --token={$ECOMZONE_CRON_TOKEN}</pre>
</div>
</div>
<div class="panel">
<div class="panel-heading">
<i class="icon-info"></i> {l s='Debug Information' mod='ecomzone'}
</div>
<div class="form-wrapper">
<div class="table-responsive">
<table class="table">
<tbody>
{foreach from=$ECOMZONE_DEBUG_INFO key=key item=value}
<tr>
<td><strong>{$key|escape:'html':'UTF-8'}</strong></td>
<td>{$value|escape:'html':'UTF-8'}</td>
</tr>
{/foreach}
<tr>
<td><strong>{l s='Last Cron Run' mod='ecomzone'}</strong></td>
<td>{$ECOMZONE_LAST_CRON_RUN|escape:'html':'UTF-8'}</td>
</tr>
<tr>
<td><strong>{l s='Next Scheduled Run' mod='ecomzone'}</strong></td>
<td>{$ECOMZONE_NEXT_CRON_RUN|escape:'html':'UTF-8'}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="panel">
<div class="panel-heading">
<i class="icon-list"></i> {l s='Recent Logs' mod='ecomzone'}
</div>
<div class="form-wrapper">
<div class="log-container" style="max-height: 400px; overflow-y: auto;">
{foreach from=$ECOMZONE_LOGS item=log}
<div class="log-line">{$log|escape:'html':'UTF-8'}</div>
{/foreach}
</div>
</div>
</div>