diff --git a/advancedmegamenu.php b/advancedmegamenu.php
index b4817dc..a0e0239 100644
--- a/advancedmegamenu.php
+++ b/advancedmegamenu.php
@@ -144,7 +144,8 @@ class AdvancedMegaMenu extends Module implements WidgetInterface
$output = $this->postProcess();
$this->context->controller->addCSS($this->_path . 'views/css/admin.css');
- $this->context->controller->addJS($this->_path . 'views/js/admin.js');
+ $adminJsPath = $this->getLocalPath() . 'views/js/admin.js';
+ $this->context->controller->addJS($this->_path . 'views/js/admin.js?v=' . (is_file($adminJsPath) ? (string) filemtime($adminJsPath) : (string) time()));
$languages = $this->context->controller->getLanguages();
$repository = new MenuRepository(Db::getInstance(), $this);
@@ -155,7 +156,7 @@ class AdvancedMegaMenu extends Module implements WidgetInterface
'advmegamenu_default_lang' => (int) Configuration::get('PS_LANG_DEFAULT'),
'advmegamenu_tree' => $tree,
'advmegamenu_ajax_url' => $this->getAdminAjaxUrl(),
- 'advmegamenu_admin_token' => Tools::getAdminTokenLite('AdminModules'),
+ 'advmegamenu_admin_token' => $this->getAjaxToken(),
]);
return $output . $this->display(__FILE__, 'views/templates/admin/configure.tpl');
@@ -197,31 +198,42 @@ class AdvancedMegaMenu extends Module implements WidgetInterface
return '';
}
+ $menuCssFile = $this->getLocalPath() . 'views/css/megamenu.css';
+ $menuJsFile = $this->getLocalPath() . 'views/js/megamenu.js';
+
$controller->registerStylesheet(
'module-advancedmegamenu-front',
$this->_path . 'views/css/megamenu.css',
- ['media' => 'all', 'priority' => 150]
+ [
+ 'media' => 'all',
+ 'priority' => 150,
+ 'version' => is_file($menuCssFile) ? (string) filemtime($menuCssFile) : $this->version,
+ ]
);
$controller->registerJavascript(
'module-advancedmegamenu-front',
$this->_path . 'views/js/megamenu.js',
- ['position' => 'bottom', 'priority' => 150]
+ [
+ 'position' => 'bottom',
+ 'priority' => 150,
+ 'version' => is_file($menuJsFile) ? (string) filemtime($menuJsFile) : $this->version,
+ ]
);
$spriteCssFile = $this->getLocalPath() . 'views/css/generated/menu-sprite.css';
if (is_file($spriteCssFile)) {
$spriteCssUrl = $this->_path . 'views/css/generated/menu-sprite.css';
- $spriteImageFile = $this->getLocalPath() . 'views/img/generated/menu-sprite.webp';
+ $spriteImageFile = $this->getCurrentSpriteImageFile($spriteCssFile);
$controller->registerStylesheet(
'module-advancedmegamenu-sprite',
$spriteCssUrl,
- ['media' => 'all', 'priority' => 151]
+ ['media' => 'all', 'priority' => 151, 'version' => (string) filemtime($spriteCssFile)]
);
$preloadLinks = [];
- if (is_file($spriteImageFile)) {
- $spriteImageUrl = $this->_path . 'views/img/generated/menu-sprite.webp?v=' . (string) filemtime($spriteImageFile);
+ if ($spriteImageFile && is_file($spriteImageFile)) {
+ $spriteImageUrl = $this->_path . 'views/img/generated/' . basename($spriteImageFile);
$preloadLinks[] = '';
}
@@ -406,15 +418,22 @@ class AdvancedMegaMenu extends Module implements WidgetInterface
private function assertAdminToken(): void
{
$token = (string) Tools::getValue('token');
- $expectedToken = Tools::getAdminTokenLite('AdminModules');
+ $validTokens = array_filter([
+ $this->getAjaxToken(),
+ Tools::getAdminTokenLite('AdminModules'),
+ ]);
- if (!hash_equals($expectedToken, $token)) {
- header('Content-Type: application/json', true, 403);
- exit(json_encode([
- 'error' => true,
- 'message' => $this->trans('Invalid admin token.', [], 'Modules.Advancedmegamenu.Admin'),
- ]));
+ foreach ($validTokens as $validToken) {
+ if (hash_equals((string) $validToken, $token)) {
+ return;
+ }
}
+
+ header('Content-Type: application/json', true, 403);
+ exit(json_encode([
+ 'error' => true,
+ 'message' => $this->trans('Invalid admin token.', [], 'Modules.Advancedmegamenu.Admin'),
+ ]));
}
private function ajaxSearchProducts(): void
@@ -615,6 +634,28 @@ class AdvancedMegaMenu extends Module implements WidgetInterface
}, $icons));
}
+ private function getCurrentSpriteImageFile(string $spriteCssFile): string
+ {
+ $css = (string) @file_get_contents($spriteCssFile);
+ if (preg_match('#img/generated/([^")]+\.webp)#', $css, $matches)) {
+ $spriteImageFile = $this->getLocalPath() . 'views/img/generated/' . basename($matches[1]);
+ if (is_file($spriteImageFile)) {
+ return $spriteImageFile;
+ }
+ }
+
+ $spriteFiles = glob($this->getLocalPath() . 'views/img/generated/menu-sprite*.webp') ?: [];
+ if (empty($spriteFiles)) {
+ return '';
+ }
+
+ usort($spriteFiles, static function (string $left, string $right): int {
+ return filemtime($right) <=> filemtime($left);
+ });
+
+ return (string) $spriteFiles[0];
+ }
+
/**
* @param array $node
*
@@ -970,6 +1011,13 @@ class AdvancedMegaMenu extends Module implements WidgetInterface
]);
}
+ private function getAjaxToken(): string
+ {
+ $employeeId = isset($this->context->employee) ? (int) $this->context->employee->id : 0;
+
+ return Tools::hash($this->name . '|ajax|' . $employeeId);
+ }
+
private function buildModuleImageUrl(string $path): string
{
return rtrim($this->getPathUri(), '/') . '/views/img/' . ltrim($path, '/');
diff --git a/src/Classes/SpriteGenerator.php b/src/Classes/SpriteGenerator.php
index e81c951..5a0e6ec 100644
--- a/src/Classes/SpriteGenerator.php
+++ b/src/Classes/SpriteGenerator.php
@@ -22,7 +22,6 @@ class SpriteGenerator
{
$imageOutputDir = $this->moduleDir . '/views/img/generated';
$cssOutputDir = $this->moduleDir . '/views/css/generated';
- $spritePath = $imageOutputDir . '/menu-sprite.webp';
$cssPath = $cssOutputDir . '/menu-sprite.css';
if (!is_dir($imageOutputDir)) {
@@ -34,7 +33,7 @@ class SpriteGenerator
}
if (empty($icons)) {
- $this->cleanup($spritePath, $cssPath);
+ $this->cleanup($imageOutputDir, $cssPath);
return;
}
@@ -77,7 +76,7 @@ class SpriteGenerator
}
if (empty($images) || $totalWidth === 0 || $maxHeight === 0) {
- $this->cleanup($spritePath, $cssPath);
+ $this->cleanup($imageOutputDir, $cssPath);
return;
}
@@ -101,22 +100,37 @@ class SpriteGenerator
);
}
- $spriteWritten = imagewebp($sprite, $spritePath, 85);
+ $tmpSpritePath = $imageOutputDir . '/menu-sprite.tmp.webp';
+ $spriteWritten = imagewebp($sprite, $tmpSpritePath, 85);
imagedestroy($sprite);
- if (!$spriteWritten || !is_file($spritePath)) {
+ if (!$spriteWritten || !is_file($tmpSpritePath)) {
foreach ($images as $iconImage) {
imagedestroy($iconImage['resource']);
}
- $this->cleanup($spritePath, $cssPath);
+ $this->cleanup($imageOutputDir, $cssPath);
+
+ return;
+ }
+
+ $hash = substr((string) sha1_file($tmpSpritePath), 0, 16);
+ $spriteFilename = 'menu-sprite-' . $hash . '.webp';
+ $spritePath = $imageOutputDir . '/' . $spriteFilename;
+
+ if (!rename($tmpSpritePath, $spritePath)) {
+ @unlink($tmpSpritePath);
+ foreach ($images as $iconImage) {
+ imagedestroy($iconImage['resource']);
+ }
+
+ $this->cleanup($imageOutputDir, $cssPath);
return;
}
- $version = is_file($spritePath) ? (string) filemtime($spritePath) : (string) time();
$css = [];
- $css[] = '.adv-megamenu__icon{display:inline-block;background-repeat:no-repeat;background-image:url("../../img/generated/menu-sprite.webp?v=' . $version . '");}';
+ $css[] = '.adv-megamenu__icon{display:inline-block;background-repeat:no-repeat;background-image:url("../../img/generated/' . $spriteFilename . '");}';
foreach ($images as $iconImage) {
$css[] = sprintf(
@@ -132,15 +146,17 @@ class SpriteGenerator
file_put_contents($cssPath, implode(PHP_EOL, $css) . PHP_EOL);
}
- private function cleanup(string $spritePath, string $cssPath): void
+ private function cleanup(string $imageOutputDir, string $cssPath): void
{
- if (is_file($spritePath)) {
- unlink($spritePath);
- }
-
if (is_file($cssPath)) {
unlink($cssPath);
}
+
+ foreach (glob($imageOutputDir . '/menu-sprite*.webp') ?: [] as $spritePath) {
+ if (is_file($spritePath)) {
+ unlink($spritePath);
+ }
+ }
}
/**
diff --git a/views/css/admin.css b/views/css/admin.css
index e9eac7c..41b8510 100644
--- a/views/css/admin.css
+++ b/views/css/admin.css
@@ -211,6 +211,19 @@
border: 1px solid #edf1f4;
}
+.adv-menu-image-preview-wrap {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+}
+
+.adv-menu-image-remove {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
.adv-menu-admin-node__collections {
display: grid;
gap: 1rem;
diff --git a/views/css/megamenu.css b/views/css/megamenu.css
index cb81be4..351396e 100644
--- a/views/css/megamenu.css
+++ b/views/css/megamenu.css
@@ -569,6 +569,31 @@
font-weight: 700;
}
+.adv-megamenu .adv-megamenu__mobile-title-link,
+.adv-megamenu .adv-megamenu__mobile-title-link:link,
+.adv-megamenu .adv-megamenu__mobile-title-link:visited {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.25rem;
+ min-width: 0;
+ color: #8a3c1f;
+ font-weight: 600;
+ line-height: 1.25;
+ text-align: right;
+ text-decoration: none;
+}
+
+.adv-megamenu__mobile-title-link .material-icons {
+ flex: 0 0 auto;
+ font-size: 1.125rem;
+}
+
+.adv-megamenu .adv-megamenu__mobile-title-link:hover,
+.adv-megamenu .adv-megamenu__mobile-title-link:focus-visible {
+ color: #8a3c1f;
+}
+
.adv-megamenu__mobile-viewport {
position: relative;
height: calc(100% - 56px);
diff --git a/views/js/admin.js b/views/js/admin.js
index c2c353c..c9f5f48 100644
--- a/views/js/admin.js
+++ b/views/js/admin.js
@@ -458,7 +458,12 @@
var rootHint = nodeModal.querySelector('.js-node-root-hint');
var imageGroup = nodeModal.querySelector('.js-node-image-group');
var previewWrap = nodeModal.querySelector('.js-node-icon-preview');
- renderImagePreview(previewWrap, depth > 0 ? node.icon_url : '');
+ renderImagePreview(previewWrap, depth > 0 ? node.icon_url : '', function () {
+ node.icon_path = '';
+ node.icon_url = '';
+ renderImagePreview(previewWrap, '');
+ serializeTree();
+ });
rootHint.style.display = depth === 0 ? 'block' : 'none';
rootHint.textContent = t('rootOnlyHint', 'For first-level items only title and custom link are edited here.');
@@ -473,7 +478,12 @@
uploadImage(event.target.files[0], 'menu_icon', function (response) {
node.icon_path = response.path;
node.icon_url = response.url;
- renderImagePreview(previewWrap, response.url);
+ renderImagePreview(previewWrap, response.url, function () {
+ node.icon_path = '';
+ node.icon_url = '';
+ renderImagePreview(previewWrap, '');
+ serializeTree();
+ });
serializeTree();
}, {
menuDepth: depth,
@@ -572,7 +582,13 @@
layoutModal.querySelector('.js-layout-type').value = layout.block_type || 'promo';
layoutModal.querySelector('.js-layout-title').checked = !!layout.show_title;
layoutModal.querySelector('.js-layout-bg').value = layout.background_color || '';
- renderImagePreview(layoutModal.querySelector('.js-layout-preview'), layout.custom_image_url);
+ var layoutPreviewWrap = layoutModal.querySelector('.js-layout-preview');
+ renderImagePreview(layoutPreviewWrap, layout.custom_image_url, function () {
+ layout.custom_image = '';
+ layout.custom_image_url = '';
+ renderImagePreview(layoutPreviewWrap, '');
+ serializeTree();
+ });
renderProducts(layout.products, layoutModal.querySelector('.js-layout-products'));
layoutModal.querySelector('.js-layout-product-results').innerHTML = '';
layoutModal.querySelector('.js-layout-product-search').value = '';
@@ -583,7 +599,12 @@
uploadImage(event.target.files[0], 'layout_image', function (response) {
layout.custom_image = response.path;
layout.custom_image_url = response.url;
- renderImagePreview(layoutModal.querySelector('.js-layout-preview'), response.url);
+ renderImagePreview(layoutPreviewWrap, response.url, function () {
+ layout.custom_image = '';
+ layout.custom_image_url = '';
+ renderImagePreview(layoutPreviewWrap, '');
+ serializeTree();
+ });
serializeTree();
});
};
@@ -678,16 +699,30 @@
showModal(confirmModal);
}
- function renderImagePreview(wrap, url) {
+ function renderImagePreview(wrap, url, onRemove) {
wrap.innerHTML = '';
if (!url) {
return;
}
+ var preview = document.createElement('div');
+ preview.className = 'adv-menu-image-preview-wrap';
+
var image = document.createElement('img');
image.src = url;
image.className = 'adv-menu-image-preview';
- wrap.appendChild(image);
+ preview.appendChild(image);
+
+ if (typeof onRemove === 'function') {
+ var removeButton = document.createElement('button');
+ removeButton.type = 'button';
+ removeButton.className = 'btn btn-default btn-sm adv-menu-image-remove';
+ removeButton.innerHTML = '' + escapeHtml(t('removeImage', 'Remove image')) + '';
+ removeButton.addEventListener('click', onRemove);
+ preview.appendChild(removeButton);
+ }
+
+ wrap.appendChild(preview);
}
function getNodeTitle(node) {
@@ -727,9 +762,6 @@
var uploadOptions = options || {};
var formData = new FormData();
- formData.append('ajax', '1');
- formData.append('action', 'uploadImage');
- formData.append('token', token);
formData.append('preset', preset || 'default');
if (typeof uploadOptions.menuDepth !== 'undefined') {
formData.append('menu_depth', String(uploadOptions.menuDepth));
@@ -739,17 +771,26 @@
}
formData.append('image', file);
- fetch(ajaxUrl, {
+ fetch(ajaxUrl + '&ajax=1&action=uploadImage&token=' + encodeURIComponent(token), {
method: 'POST',
body: formData
})
.then(function (response) {
+ if (!response.ok) {
+ throw new Error('Upload failed: HTTP ' + response.status);
+ }
+
return response.json();
})
.then(function (payload) {
if (payload && !payload.error) {
callback(payload);
+ return;
}
+ throw new Error(payload && payload.message ? payload.message : 'Upload failed');
+ })
+ .catch(function (error) {
+ window.alert(error.message || 'Upload failed');
});
}
diff --git a/views/js/megamenu.js b/views/js/megamenu.js
index 68d0412..a71a0cc 100644
--- a/views/js/megamenu.js
+++ b/views/js/megamenu.js
@@ -219,14 +219,6 @@
return;
}
- item.addEventListener('mouseenter', function () {
- if (!isDesktop()) {
- return;
- }
-
- activateSubmenuPane(item);
- });
-
bindDesktopClickIntent(link, function () {
activateSubmenuPane(item);
});
diff --git a/views/templates/admin/configure.tpl b/views/templates/admin/configure.tpl
index f83af70..dba8130 100644
--- a/views/templates/admin/configure.tpl
+++ b/views/templates/admin/configure.tpl
@@ -43,6 +43,7 @@
"description": "{l s='Description' d='Modules.Advancedmegamenu.Admin' js=1}",
"customLinkLabel": "{l s='Custom link' d='Modules.Advancedmegamenu.Admin' js=1}",
"iconUpload": "{l s='Item image/icon' d='Modules.Advancedmegamenu.Admin' js=1}",
+ "removeImage": "{l s='Remove image' d='Modules.Advancedmegamenu.Admin' js=1}",
"rootOnlyHint": "{l s='For first-level items only title and custom link are edited here.' d='Modules.Advancedmegamenu.Admin' js=1}",
"width": "{l s='Width' d='Modules.Advancedmegamenu.Admin' js=1}",
"blockType": "{l s='Block type' d='Modules.Advancedmegamenu.Admin' js=1}",
diff --git a/views/templates/hook/megamenu.tpl b/views/templates/hook/megamenu.tpl
index 36241f0..03cdcdc 100644
--- a/views/templates/hook/megamenu.tpl
+++ b/views/templates/hook/megamenu.tpl
@@ -174,7 +174,14 @@
- {$node.title|escape:'htmlall':'UTF-8'}
+ {if $node.url && $node.url != '#'}
+
+ {else}
+
+ {/if}