Build advanced mega menu module
Build / Build & Release draft (push) Failing after 0s
PHP tests / PHP Syntax check 5.6 => 8.1 (push) Failing after 33s
PHP tests / PHPStan (1.7.1.2) (push) Has been cancelled
PHP tests / PHPStan (1.7.2.5) (push) Has been cancelled
PHP tests / PHPStan (1.7.3.4) (push) Has been cancelled
PHP tests / PHPStan (1.7.4.4) (push) Has been cancelled
PHP tests / PHPStan (1.7.5.1) (push) Has been cancelled
PHP tests / PHPStan (1.7.6) (push) Has been cancelled
PHP tests / PHPStan (1.7.7) (push) Has been cancelled
PHP tests / PHPStan (1.7.8) (push) Has been cancelled
PHP tests / PHPStan (latest) (push) Has been cancelled
PHP tests / PHP-CS-Fixer (push) Has been cancelled

This commit is contained in:
tiamak
2026-04-09 13:29:59 +00:00
parent 04a2ed079a
commit 8e6b6d0cdd
15 changed files with 3207 additions and 47 deletions
+3
View File
@@ -2,3 +2,6 @@
/translations/*.php
/vendor/
/.php_cs.cache
/views/img/uploads/
/views/img/generated/
/views/css/generated/
+10 -29
View File
@@ -1,36 +1,17 @@
# Main Menu
# Advanced Mega Menu
## About
Rich navigation module for PrestaShop 8.x / 1.7.x based on the architectural approach of `ps_mainmenu`, but rebuilt for:
Make it easy for your visitors to find their way on your store, select the right link and turn it into a menu item.
- desktop full-width mega menu panels
- mobile slide-in drill-down navigation
- configurable rich content blocks with attached products
- automatic WebP image handling
- dynamic icon sprite generation
## Compatibility
PrestaShop: `1.7.1.0` or later
PrestaShop `1.7.8+` and `8.x`
## How to test
## Notes
Link to specs : https://docs.prestashop-project.org/functional-documentation/functional-documentation/ux-ui/back-office/improve/modules/main-menu-ps_mainmenu
Add/remove existing links to menu
Change position
Add new custom links
## Reporting issues
You can report issues with this module in the main PrestaShop repository. [Click here to report an issue][report-issue].
## Contributing
PrestaShop modules are open source extensions to the [PrestaShop e-commerce platform][prestashop]. Everyone is welcome and even encouraged to contribute with their own improvements!
Just make sure to follow our [contribution guidelines][contribution-guidelines].
## License
This module is released under the [Academic Free License 3.0][AFL-3.0]
[report-issue]: https://github.com/PrestaShop/PrestaShop/issues/new/choose
[prestashop]: https://www.prestashop.com/
[contribution-guidelines]: https://devdocs.prestashop.com/1.7/contribute/contribution-guidelines/project-modules/
[AFL-3.0]: https://opensource.org/licenses/AFL-3.0
The repository still contains the original `ps_mainmenu` forked files for reference. The active module entrypoint is `advancedmegamenu.php`.
+918
View File
@@ -0,0 +1,918 @@
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
require_once __DIR__ . '/src/autoload.php';
use AdvancedMegaMenu\Classes\SpriteGenerator;
use AdvancedMegaMenu\Model\AdvMenuNode;
use AdvancedMegaMenu\Repository\MenuRepository;
use PrestaShop\PrestaShop\Core\Module\WidgetInterface;
class AdvancedMegaMenu extends Module implements WidgetInterface
{
private const MENU_JSON_CACHE_KEY = 'ADVANCEDMEGAMENU_MENU_JSON';
private const CACHE_DIR_NAME = 'advancedmegamenu';
private const ALLOWED_TYPES = ['category', 'cms', 'link', 'rich_content'];
/** @var array<int, int> */
protected $user_groups = [];
public function __construct()
{
$this->name = 'advancedmegamenu';
$this->tab = 'front_office_features';
$this->version = '1.0.0';
$this->author = 'Tiamak';
$this->bootstrap = true;
parent::__construct();
$this->displayName = $this->trans('Advanced Mega Menu', [], 'Modules.Advancedmegamenu.Admin');
$this->description = $this->trans('Rich mega menu with desktop panels, mobile drill-down navigation and optimized assets.', [], 'Modules.Advancedmegamenu.Admin');
$this->ps_versions_compliancy = ['min' => '1.7.8.0', 'max' => _PS_VERSION_];
}
public function install()
{
return parent::install()
&& $this->registerHook('displayTop')
&& $this->registerHook('displayHeader')
&& $this->registerHook('actionObjectCategoryUpdateAfter')
&& $this->registerHook('actionObjectCategoryDeleteAfter')
&& $this->registerHook('actionObjectCategoryAddAfter')
&& $this->registerHook('actionObjectCmsUpdateAfter')
&& $this->registerHook('actionObjectCmsDeleteAfter')
&& $this->registerHook('actionObjectCmsAddAfter')
&& $this->registerHook('actionObjectProductUpdateAfter')
&& $this->registerHook('actionObjectProductDeleteAfter')
&& $this->registerHook('actionObjectProductAddAfter')
&& $this->registerHook('actionCategoryUpdate')
&& $this->registerHook('actionMetaPageSave')
&& $this->registerHook('actionShopDataDuplication')
&& $this->installDb();
}
public function uninstall()
{
$this->clearMenuCache();
return parent::uninstall() && $this->uninstallDb();
}
public function installDb()
{
$queries = [
'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'advmegamenu_item` (
`id_advmegamenu_item` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`id_shop` INT UNSIGNED NOT NULL,
`parent_id` INT UNSIGNED NOT NULL DEFAULT 0,
`position` INT UNSIGNED NOT NULL DEFAULT 0,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`type` VARCHAR(32) NOT NULL,
`entity_id` INT UNSIGNED NOT NULL DEFAULT 0,
`icon_path` VARCHAR(255) NOT NULL DEFAULT "",
`new_window` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id_advmegamenu_item`),
KEY `advmegamenu_item_shop` (`id_shop`, `parent_id`, `position`)
) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4;',
'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'advmegamenu_item_lang` (
`id_advmegamenu_item` INT UNSIGNED NOT NULL,
`id_lang` INT UNSIGNED NOT NULL,
`title` VARCHAR(255) NOT NULL DEFAULT "",
`description` TEXT NULL,
`custom_link` VARCHAR(255) NOT NULL DEFAULT "",
PRIMARY KEY (`id_advmegamenu_item`, `id_lang`)
) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4;',
'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'advmegamenu_layout` (
`id_advmegamenu_layout` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`id_advmegamenu_item` INT UNSIGNED NOT NULL,
`position` INT UNSIGNED NOT NULL DEFAULT 0,
`column_width` TINYINT UNSIGNED NOT NULL DEFAULT 4,
`show_title` TINYINT(1) NOT NULL DEFAULT 1,
`custom_image` VARCHAR(255) NOT NULL DEFAULT "",
`background_color` VARCHAR(32) NOT NULL DEFAULT "",
`block_type` VARCHAR(32) NOT NULL DEFAULT "promo",
PRIMARY KEY (`id_advmegamenu_layout`),
KEY `advmegamenu_layout_item` (`id_advmegamenu_item`, `position`)
) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4;',
'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'advmegamenu_products` (
`id_advmegamenu_products` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`id_advmegamenu_layout` INT UNSIGNED NOT NULL,
`id_product` INT UNSIGNED NOT NULL,
`position` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id_advmegamenu_products`),
KEY `advmegamenu_products_layout` (`id_advmegamenu_layout`, `position`)
) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4;',
'INSERT IGNORE INTO `' . _DB_PREFIX_ . 'hook` (`name`, `title`, `description`) VALUES
("actionMainMenuModifier", "Modify advanced mega menu view data", "Allows modules to alter advanced mega menu data");',
];
foreach ($queries as $query) {
if (!Db::getInstance()->execute($query)) {
return false;
}
}
return true;
}
protected function uninstallDb()
{
$queries = [
'DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'advmegamenu_products`',
'DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'advmegamenu_layout`',
'DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'advmegamenu_item_lang`',
'DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'advmegamenu_item`',
];
foreach ($queries as $query) {
Db::getInstance()->execute($query);
}
return true;
}
public function getContent()
{
if (Tools::getValue('ajax') && Tools::getValue('action')) {
$this->ajaxRouter();
}
$output = $this->postProcess();
$this->context->controller->addCSS($this->_path . 'views/css/admin.css');
$this->context->controller->addJS($this->_path . 'views/js/admin.js');
$languages = $this->context->controller->getLanguages();
$repository = new MenuRepository(Db::getInstance(), $this);
$tree = $repository->getAdminTree((int) $this->context->shop->id, $languages);
$this->context->smarty->assign([
'advmegamenu_languages' => $languages,
'advmegamenu_default_lang' => (int) Configuration::get('PS_LANG_DEFAULT'),
'advmegamenu_tree' => $tree,
'advmegamenu_ajax_url' => $this->getAdminAjaxUrl(),
'advmegamenu_admin_token' => Tools::getAdminTokenLite('AdminModules'),
]);
return $output . $this->display(__FILE__, 'views/templates/admin/configure.tpl');
}
protected function postProcess()
{
if (!Tools::isSubmit('submitAdvancedMegaMenu')) {
return '';
}
$rawTree = Tools::getValue('advmegamenu_tree_json', '[]');
$tree = json_decode($rawTree, true);
if (!is_array($tree)) {
return $this->displayError($this->trans('Invalid menu payload.', [], 'Modules.Advancedmegamenu.Admin'));
}
try {
$this->saveMenuTree($tree);
$this->generateSpriteAssets((int) $this->context->shop->id);
$this->clearMenuCache();
return $this->displayConfirmation($this->trans('The mega menu configuration has been saved.', [], 'Modules.Advancedmegamenu.Admin'));
} catch (Exception $exception) {
return $this->displayError($exception->getMessage());
}
}
public function hookDisplayTop($params)
{
return $this->renderWidget('displayTop', $params);
}
public function hookDisplayHeader()
{
$controller = $this->context->controller;
if (!$controller) {
return;
}
$controller->registerStylesheet(
'module-advancedmegamenu-front',
$this->_path . 'views/css/megamenu.css',
['media' => 'all', 'priority' => 150]
);
$controller->registerJavascript(
'module-advancedmegamenu-front',
$this->_path . 'views/js/megamenu.js',
['position' => 'bottom', 'priority' => 150]
);
$spriteCssFile = $this->getLocalPath() . 'views/css/generated/menu-sprite.css';
if (is_file($spriteCssFile)) {
$controller->registerStylesheet(
'module-advancedmegamenu-sprite',
$this->_path . 'views/css/generated/menu-sprite.css',
['media' => 'all', 'priority' => 151]
);
}
}
public function getWidgetVariables($hookName, array $configuration)
{
$idLang = (int) $this->context->language->id;
$idShop = (int) $this->context->shop->id;
$this->user_groups = Customer::getGroupsStatic((int) $this->context->customer->id);
$groupsKey = empty($this->user_groups) ? '' : '_' . implode('_', $this->user_groups);
$cacheFile = $this->getCacheDirectory() . DIRECTORY_SEPARATOR . self::MENU_JSON_CACHE_KEY . '_' . $idLang . '_' . $idShop . $groupsKey . '.json';
$menu = json_decode((string) @file_get_contents($cacheFile), true);
if (!is_array($menu)) {
$repository = new MenuRepository(Db::getInstance(), $this);
$rawTree = $repository->getRawTree($idShop, $idLang);
$menu = [
'children' => array_map(function (array $node): array {
return $this->buildFrontNode($node);
}, $rawTree),
];
file_put_contents($cacheFile, json_encode($menu));
}
$pageIdentifier = $this->getCurrentPageIdentifier();
return $this->mapTree(function (array $node) use ($pageIdentifier): array {
$node['current'] = ($pageIdentifier === ($node['page_identifier'] ?? null));
return $node;
}, $menu);
}
public function renderWidget($hookName, array $configuration)
{
$menu = $this->getWidgetVariables($hookName, $configuration);
Hook::exec('actionMainMenuModifier', ['menu' => &$menu]);
$this->smarty->assign([
'menu' => $menu,
]);
return $this->display(__FILE__, 'views/templates/hook/megamenu.tpl');
}
/**
* @param array<int, array<string, mixed>> $tree
*/
private function saveMenuTree(array $tree): void
{
$idShop = (int) $this->context->shop->id;
$languages = Language::getLanguages(false);
Db::getInstance()->execute('START TRANSACTION');
try {
$this->deleteMenuTreeForShop($idShop);
foreach (array_values($tree) as $position => $node) {
$this->insertNodeRecursive($node, 0, $position, $idShop, $languages);
}
Db::getInstance()->execute('COMMIT');
} catch (Exception $exception) {
Db::getInstance()->execute('ROLLBACK');
throw $exception;
}
}
private function deleteMenuTreeForShop(int $idShop): void
{
$itemIds = Db::getInstance()->executeS(
'SELECT `id_advmegamenu_item` FROM `' . _DB_PREFIX_ . 'advmegamenu_item` WHERE `id_shop` = ' . (int) $idShop
);
$itemIds = array_map(static function (array $row): int {
return (int) $row['id_advmegamenu_item'];
}, $itemIds ?: []);
if (!empty($itemIds)) {
$layoutIds = Db::getInstance()->executeS(
'SELECT `id_advmegamenu_layout` FROM `' . _DB_PREFIX_ . 'advmegamenu_layout`
WHERE `id_advmegamenu_item` IN (' . implode(',', array_map('intval', $itemIds)) . ')'
);
$layoutIds = array_map(static function (array $row): int {
return (int) $row['id_advmegamenu_layout'];
}, $layoutIds ?: []);
if (!empty($layoutIds)) {
Db::getInstance()->delete('advmegamenu_products', 'id_advmegamenu_layout IN (' . implode(',', array_map('intval', $layoutIds)) . ')');
}
Db::getInstance()->delete('advmegamenu_layout', 'id_advmegamenu_item IN (' . implode(',', array_map('intval', $itemIds)) . ')');
Db::getInstance()->delete('advmegamenu_item_lang', 'id_advmegamenu_item IN (' . implode(',', array_map('intval', $itemIds)) . ')');
Db::getInstance()->delete('advmegamenu_item', 'id_advmegamenu_item IN (' . implode(',', array_map('intval', $itemIds)) . ')');
}
}
/**
* @param array<string, mixed> $node
* @param array<int, array{id_lang:int}> $languages
*/
private function insertNodeRecursive(array $node, int $parentId, int $position, int $idShop, array $languages): void
{
$type = in_array($node['type'] ?? '', self::ALLOWED_TYPES, true) ? $node['type'] : 'link';
$item = new AdvMenuNode();
$item->id_shop = $idShop;
$item->parent_id = $parentId;
$item->position = $position;
$item->active = !empty($node['active']);
$item->type = $type;
$item->entity_id = max(0, (int) ($node['entity_id'] ?? 0));
$item->icon_path = (string) ($node['icon_path'] ?? '');
$item->new_window = !empty($node['new_window']);
if (!$item->add()) {
throw new Exception($this->trans('Unable to save menu item.', [], 'Modules.Advancedmegamenu.Admin'));
}
foreach ($languages as $language) {
$idLang = (int) $language['id_lang'];
$langData = $node['lang'][$idLang] ?? [];
Db::getInstance()->insert('advmegamenu_item_lang', [
'id_advmegamenu_item' => (int) $item->id,
'id_lang' => $idLang,
'title' => pSQL((string) ($langData['title'] ?? ''), true),
'description' => pSQL((string) ($langData['description'] ?? ''), true),
'custom_link' => pSQL((string) ($langData['custom_link'] ?? ''), true),
]);
}
foreach (array_values($node['layouts'] ?? []) as $layoutPosition => $layout) {
Db::getInstance()->insert('advmegamenu_layout', [
'id_advmegamenu_item' => (int) $item->id,
'position' => (int) $layoutPosition,
'column_width' => min(12, max(1, (int) ($layout['column_width'] ?? 4))),
'show_title' => !empty($layout['show_title']),
'custom_image' => pSQL((string) ($layout['custom_image'] ?? '')),
'background_color' => pSQL((string) ($layout['background_color'] ?? '')),
'block_type' => pSQL((string) ($layout['block_type'] ?? 'promo')),
]);
$layoutId = (int) Db::getInstance()->Insert_ID();
foreach (array_values($layout['products'] ?? []) as $productPosition => $idProduct) {
Db::getInstance()->insert('advmegamenu_products', [
'id_advmegamenu_layout' => $layoutId,
'id_product' => (int) $idProduct,
'position' => (int) $productPosition,
]);
}
}
foreach (array_values($node['children'] ?? []) as $childPosition => $child) {
$this->insertNodeRecursive($child, (int) $item->id, $childPosition, $idShop, $languages);
}
}
private function ajaxRouter(): void
{
$action = (string) Tools::getValue('action');
if ($action === 'searchProducts') {
$this->ajaxSearchProducts();
}
if ($action === 'uploadImage') {
$this->ajaxUploadImage();
}
header('Content-Type: application/json');
die(json_encode(['error' => true, 'message' => 'Unknown action']));
}
private function ajaxSearchProducts(): void
{
$term = pSQL((string) Tools::getValue('q'));
$idLang = (int) $this->context->language->id;
$idShop = (int) $this->context->shop->id;
$rows = Db::getInstance()->executeS(
'SELECT p.`id_product`, pl.`name`, p.`reference`
FROM `' . _DB_PREFIX_ . 'product` p
INNER JOIN `' . _DB_PREFIX_ . 'product_shop` ps ON (ps.`id_product` = p.`id_product` AND ps.`id_shop` = ' . $idShop . ')
INNER JOIN `' . _DB_PREFIX_ . 'product_lang` pl ON (
pl.`id_product` = p.`id_product`
AND pl.`id_lang` = ' . $idLang . '
AND pl.`id_shop` = ' . $idShop . '
)
WHERE pl.`name` LIKE "%' . $term . '%" OR p.`reference` LIKE "%' . $term . '%" OR p.`id_product` = "' . (int) $term . '"
ORDER BY pl.`name` ASC
LIMIT 20'
) ?: [];
$results = array_map(static function (array $row): array {
return [
'id' => (int) $row['id_product'],
'label' => trim(sprintf('#%d %s%s', $row['id_product'], $row['name'], $row['reference'] ? ' (' . $row['reference'] . ')' : '')),
];
}, $rows);
header('Content-Type: application/json');
die(json_encode(['results' => $results]));
}
private function ajaxUploadImage(): void
{
if (empty($_FILES['image']['tmp_name'])) {
header('Content-Type: application/json');
die(json_encode(['error' => true, 'message' => 'Missing file']));
}
$preset = (string) Tools::getValue('preset', 'default');
$storedPath = $this->storeUploadedImage($_FILES['image'], $preset);
header('Content-Type: application/json');
die(json_encode([
'error' => false,
'path' => $storedPath,
'url' => $this->buildModuleImageUrl($storedPath),
]));
}
/**
* @param array<string, mixed> $file
*/
private function storeUploadedImage(array $file, string $preset = 'default'): string
{
$uploadDir = $this->getLocalPath() . 'views/img/uploads/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}
$extension = Tools::strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION));
$basename = sha1(uniqid((string) mt_rand(), true));
$targetPath = $uploadDir . $basename . '.webp';
$originalPath = $uploadDir . $basename . ($extension ? '.' . $extension : '.bin');
if (!move_uploaded_file((string) $file['tmp_name'], $originalPath)) {
throw new Exception($this->trans('Unable to store uploaded file.', [], 'Modules.Advancedmegamenu.Admin'));
}
$converted = false;
if ($preset === 'menu_icon') {
$converted = $this->convertImageToSquareWebp($originalPath, $targetPath, 250);
}
if (!$converted) {
$converted = $this->convertImageToWebp($originalPath, $targetPath);
}
if ($converted) {
unlink($originalPath);
return 'uploads/' . basename($targetPath);
}
return 'uploads/' . basename($originalPath);
}
private function convertImageToWebp(string $sourcePath, string $targetPath): bool
{
$contents = @file_get_contents($sourcePath);
if ($contents === false) {
return false;
}
$image = @imagecreatefromstring($contents);
if (!$image) {
return false;
}
imagepalettetotruecolor($image);
imagealphablending($image, true);
imagesavealpha($image, true);
$result = imagewebp($image, $targetPath, 85);
imagedestroy($image);
return $result && is_file($targetPath);
}
private function convertImageToSquareWebp(string $sourcePath, string $targetPath, int $size): bool
{
$contents = @file_get_contents($sourcePath);
if ($contents === false) {
return false;
}
$sourceImage = @imagecreatefromstring($contents);
if (!$sourceImage) {
return false;
}
$sourceWidth = imagesx($sourceImage);
$sourceHeight = imagesy($sourceImage);
if ($sourceWidth <= 0 || $sourceHeight <= 0) {
imagedestroy($sourceImage);
return false;
}
$scale = $size / max($sourceWidth, $sourceHeight);
$targetWidth = max(1, (int) round($sourceWidth * $scale));
$targetHeight = max(1, (int) round($sourceHeight * $scale));
$destinationX = (int) floor(($size - $targetWidth) / 2);
$destinationY = (int) floor(($size - $targetHeight) / 2);
$canvas = imagecreatetruecolor($size, $size);
if (!$canvas) {
imagedestroy($sourceImage);
return false;
}
$white = imagecolorallocate($canvas, 255, 255, 255);
imagefill($canvas, 0, 0, $white);
imagealphablending($canvas, true);
imagesavealpha($canvas, false);
$result = imagecopyresampled(
$canvas,
$sourceImage,
$destinationX,
$destinationY,
0,
0,
$targetWidth,
$targetHeight,
$sourceWidth,
$sourceHeight
) && imagewebp($canvas, $targetPath, 85);
imagedestroy($canvas);
imagedestroy($sourceImage);
return $result && is_file($targetPath);
}
private function generateSpriteAssets(int $idShop): void
{
$icons = Db::getInstance()->executeS(
'SELECT `id_advmegamenu_item` AS `id`, `icon_path`
FROM `' . _DB_PREFIX_ . 'advmegamenu_item`
WHERE `id_shop` = ' . (int) $idShop . '
AND `active` = 1
AND `icon_path` != ""'
) ?: [];
$generator = new SpriteGenerator($this->getLocalPath(), $this->getPathUri());
$generator->generate(array_map(static function (array $row): array {
return [
'id' => (int) $row['id'],
'icon_path' => (string) $row['icon_path'],
];
}, $icons));
}
/**
* @param array<string, mixed> $node
*
* @return array<string, mixed>
*/
private function buildFrontNode(array $node): array
{
$title = (string) ($node['title'] ?? '');
$description = (string) ($node['description'] ?? '');
$entityId = (int) ($node['entity_id'] ?? 0);
$itemId = (int) ($node['id'] ?? 0);
$type = (string) ($node['type'] ?? 'link');
$frontNode = [
'id' => $itemId,
'title' => $title,
'description' => $description,
'url' => $this->resolveNodeUrl($node),
'custom_link' => (string) ($node['custom_link'] ?? ''),
'type' => $type,
'active' => !empty($node['active']),
'current' => false,
'new_window' => !empty($node['new_window']),
'page_identifier' => $this->resolvePageIdentifier($type, $entityId, $node),
'icon_class' => $node['icon_path'] ? 'adv-megamenu__icon adv-icon-item-' . $itemId : '',
'icon_url' => (string) ($node['icon_url'] ?? ''),
'children' => array_values(array_filter(array_map(function (array $child): array {
return $this->buildFrontNode($child);
}, $node['children'] ?? []), static function (array $child): bool {
return $child['active'];
})),
'layouts' => $this->buildLayouts($node),
'category_branch' => $type === 'category' && $entityId > 0 ? $this->buildCategoryBranch($entityId) : [],
];
return $frontNode;
}
/**
* @param array<string, mixed> $node
*
* @return array<int, array<string, mixed>>
*/
private function buildLayouts(array $node): array
{
$title = (string) ($node['title'] ?? '');
$description = (string) ($node['description'] ?? '');
return array_map(function (array $layout) use ($title, $description): array {
return [
'id' => (int) $layout['id'],
'column_width' => (int) $layout['column_width'],
'show_title' => (bool) $layout['show_title'],
'custom_image_url' => (string) ($layout['custom_image_url'] ?? ''),
'background_color' => (string) ($layout['background_color'] ?? ''),
'block_type' => (string) ($layout['block_type'] ?? 'promo'),
'title' => $title,
'description' => $description,
'products' => $this->presentProducts($layout['products'] ?? []),
];
}, $node['layouts'] ?? []);
}
/**
* @param array<int, int> $productIds
*
* @return array<int, array<string, mixed>>
*/
private function presentProducts(array $productIds): array
{
if (empty($productIds)) {
return [];
}
$assembler = new ProductAssembler($this->context);
$presenterFactory = new ProductPresenterFactory($this->context);
$presentationSettings = $presenterFactory->getPresentationSettings();
$presenter = $presenterFactory->getPresenter();
$products = [];
foreach ($productIds as $productId) {
$product = new Product((int) $productId, false, (int) $this->context->language->id, (int) $this->context->shop->id);
if (!Validate::isLoadedObject($product) || !$product->active) {
continue;
}
$products[] = $presenter->present(
$presentationSettings,
$assembler->assembleProduct(['id_product' => (int) $productId]),
$this->context->language
);
}
return $products;
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildCategoryBranch(int $categoryId): array
{
$categories = Category::getNestedCategories($categoryId, (int) $this->context->language->id, false, $this->user_groups);
if (!isset($categories[$categoryId]['children']) || !is_array($categories[$categoryId]['children'])) {
return [];
}
return $this->buildCategoryTreeNodes($categories[$categoryId]['children']);
}
/**
* @param array<int, array<string, mixed>> $categories
*
* @return array<int, array<string, mixed>>
*/
private function buildCategoryTreeNodes(array $categories): array
{
$nodes = [];
foreach ($categories as $category) {
if (empty($category['active'])) {
continue;
}
$nodes[] = [
'id' => (int) $category['id_category'],
'title' => (string) $category['name'],
'url' => $this->context->link->getCategoryLink((int) $category['id_category']),
'children' => !empty($category['children']) ? $this->buildCategoryTreeNodes($category['children']) : [],
];
}
return $nodes;
}
/**
* @param array<string, mixed> $node
*/
private function resolveNodeUrl(array $node): string
{
$type = (string) ($node['type'] ?? 'link');
$entityId = (int) ($node['entity_id'] ?? 0);
$customLink = (string) ($node['custom_link'] ?? '');
switch ($type) {
case 'category':
return $entityId > 0 ? $this->context->link->getCategoryLink($entityId) : '#';
case 'cms':
if ($entityId <= 0) {
return '#';
}
return $this->context->link->getCMSLink(new CMS($entityId, (int) $this->context->language->id));
case 'rich_content':
case 'link':
default:
return $customLink !== '' ? $customLink : '#';
}
}
/**
* @param array<string, mixed> $node
*/
private function resolvePageIdentifier(string $type, int $entityId, array $node): string
{
switch ($type) {
case 'category':
return 'category-' . $entityId;
case 'cms':
return 'cms-page-' . $entityId;
case 'link':
case 'rich_content':
default:
return 'adv-link-' . md5((string) ($node['custom_link'] ?? '') . (string) ($node['title'] ?? ''));
}
}
private function getCurrentPageIdentifier(): string
{
$controllerName = Dispatcher::getInstance()->getController();
if ($controllerName === 'cms' && ($id = (int) Tools::getValue('id_cms'))) {
return 'cms-page-' . $id;
}
if ($controllerName === 'category' && ($id = (int) Tools::getValue('id_category'))) {
return 'category-' . $id;
}
return 'route:' . $controllerName;
}
/**
* @param callable $callback
* @param array<string, mixed> $node
*
* @return array<string, mixed>
*/
private function mapTree(callable $callback, array $node): array
{
$node['children'] = array_map(function (array $child) use ($callback): array {
return $this->mapTree($callback, $child);
}, $node['children'] ?? []);
return $callback($node);
}
private function getCacheDirectory(): string
{
$dir = _PS_CACHE_DIR_ . self::CACHE_DIR_NAME;
if (isset($this->context->customer)) {
$groups = $this->context->customer->getGroups();
if (!empty($groups)) {
$dir .= '/' . implode('_', $groups);
}
}
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
return $dir;
}
protected function clearMenuCache()
{
$this->cleanMenuCacheDirectory(_PS_CACHE_DIR_ . self::CACHE_DIR_NAME);
}
private function cleanMenuCacheDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
foreach (scandir($dir) as $entry) {
if (in_array($entry, ['.', '..'], true)) {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $entry;
if (is_dir($path)) {
$this->cleanMenuCacheDirectory($path);
continue;
}
if (preg_match('/\.json$/', $entry)) {
unlink($path);
}
}
}
public function hookActionObjectCategoryAddAfter($params)
{
$this->clearMenuCache();
}
public function hookActionObjectCategoryUpdateAfter($params)
{
$this->clearMenuCache();
}
public function hookActionObjectCategoryDeleteAfter($params)
{
$this->clearMenuCache();
}
public function hookActionObjectCmsUpdateAfter($params)
{
$this->clearMenuCache();
}
public function hookActionObjectCmsDeleteAfter($params)
{
$this->clearMenuCache();
}
public function hookActionObjectCmsAddAfter($params)
{
$this->clearMenuCache();
}
public function hookActionObjectProductUpdateAfter($params)
{
$this->clearMenuCache();
}
public function hookActionObjectProductDeleteAfter($params)
{
$this->clearMenuCache();
}
public function hookActionObjectProductAddAfter($params)
{
$this->clearMenuCache();
}
public function hookActionCategoryUpdate($params)
{
$this->clearMenuCache();
}
public function hookActionMetaPageSave($params)
{
$this->clearMenuCache();
}
public function hookActionShopDataDuplication($params)
{
$sourceShopId = (int) $params['old_id_shop'];
$targetShopId = (int) $params['new_id_shop'];
$repository = new MenuRepository(Db::getInstance(), $this);
$tree = $repository->getAdminTree($sourceShopId, Language::getLanguages(false));
if (empty($tree)) {
return;
}
$currentShop = $this->context->shop;
$this->context->shop = new Shop($targetShopId);
try {
$this->saveMenuTree($tree);
$this->generateSpriteAssets($targetShopId);
} catch (Exception $exception) {
}
$this->context->shop = $currentShop;
}
private function getAdminAjaxUrl(): string
{
return $this->context->link->getAdminLink('AdminModules', true, [], [
'configure' => $this->name,
'module_name' => $this->name,
'tab_module' => $this->tab,
]);
}
private function buildModuleImageUrl(string $path): string
{
return rtrim($this->getPathUri(), '/') . '/views/img/' . ltrim($path, '/');
}
}
+6 -12
View File
@@ -1,16 +1,15 @@
{
"name": "prestashop/ps_mainmenu",
"description": "PrestaShop - Main menu",
"homepage": "https://github.com/PrestaShop/ps_mainmenu",
"name": "tiamak/advancedmegamenu",
"description": "PrestaShop advanced mega menu module",
"homepage": "http://54.38.205.168:3000/tiamak/ps_mainmenu_tia",
"license": "AFL-3.0",
"authors": [
{
"name": "PrestaShop SA",
"email": "contact@prestashop.com"
"name": "Tiamak"
}
],
"require": {
"php": ">=5.6.0"
"php": ">=7.4"
},
"require-dev": {
"prestashop/php-dev-tools": "^4.3"
@@ -19,10 +18,5 @@
"preferred-install": "dist",
"prepend-autoloader": false
},
"type": "prestashop-module",
"autoload": {
"classmap": [
"ps_mainmenu.php"
]
}
"type": "prestashop-module"
}
+5 -5
View File
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<module>
<name>ps_mainmenu</name>
<displayName><![CDATA[Main menu]]></displayName>
<version><![CDATA[2.3.6]]></version>
<description><![CDATA[Adds a new menu to the top of your e-commerce website.]]></description>
<author><![CDATA[PrestaShop]]></author>
<name>advancedmegamenu</name>
<displayName><![CDATA[Advanced Mega Menu]]></displayName>
<version><![CDATA[1.0.0]]></version>
<description><![CDATA[Rich mega menu with desktop panels, mobile slide-in navigation, WebP assets and dynamic icon sprites.]]></description>
<author><![CDATA[Tiamak]]></author>
<tab><![CDATA[front_office_features]]></tab>
<is_configurable>1</is_configurable>
<need_instance>1</need_instance>
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace AdvancedMegaMenu\Classes;
class SpriteGenerator
{
private string $moduleDir;
private string $moduleUri;
public function __construct(string $moduleDir, string $moduleUri)
{
$this->moduleDir = rtrim($moduleDir, '/');
$this->moduleUri = rtrim($moduleUri, '/') . '/';
}
/**
* @param array<int, array{id:int, icon_path:string}> $icons
*/
public function generate(array $icons): void
{
$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)) {
mkdir($imageOutputDir, 0775, true);
}
if (!is_dir($cssOutputDir)) {
mkdir($cssOutputDir, 0775, true);
}
if (empty($icons)) {
$this->cleanup($spritePath, $cssPath);
return;
}
$images = [];
$totalWidth = 0;
$maxHeight = 0;
foreach ($icons as $icon) {
$sourcePath = $this->moduleDir . '/views/img/' . ltrim($icon['icon_path'], '/');
if (!is_file($sourcePath)) {
continue;
}
$image = $this->createImageResource($sourcePath);
if (!$image) {
continue;
}
$width = imagesx($image);
$height = imagesy($image);
$images[] = [
'id' => (int) $icon['id'],
'resource' => $image,
'width' => $width,
'height' => $height,
'x' => $totalWidth,
];
$totalWidth += $width;
$maxHeight = max($maxHeight, $height);
}
if (empty($images) || $totalWidth === 0 || $maxHeight === 0) {
$this->cleanup($spritePath, $cssPath);
return;
}
$sprite = imagecreatetruecolor($totalWidth, $maxHeight);
imagealphablending($sprite, false);
imagesavealpha($sprite, true);
$transparent = imagecolorallocatealpha($sprite, 0, 0, 0, 127);
imagefill($sprite, 0, 0, $transparent);
foreach ($images as $iconImage) {
imagecopy(
$sprite,
$iconImage['resource'],
$iconImage['x'],
0,
0,
0,
$iconImage['width'],
$iconImage['height']
);
}
imagewebp($sprite, $spritePath, 85);
imagedestroy($sprite);
$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 . '");}';
foreach ($images as $iconImage) {
$css[] = sprintf(
'.adv-icon-item-%d{width:%dpx;height:%dpx;background-position:-%dpx 0;}',
$iconImage['id'],
$iconImage['width'],
$iconImage['height'],
$iconImage['x']
);
imagedestroy($iconImage['resource']);
}
file_put_contents($cssPath, implode(PHP_EOL, $css) . PHP_EOL);
}
private function cleanup(string $spritePath, string $cssPath): void
{
if (is_file($spritePath)) {
unlink($spritePath);
}
if (is_file($cssPath)) {
unlink($cssPath);
}
}
/**
* @return resource|false
*/
private function createImageResource(string $path)
{
$contents = @file_get_contents($path);
if ($contents === false) {
return false;
}
return @imagecreatefromstring($contents);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace AdvancedMegaMenu\Model;
class AdvMenuNode extends \ObjectModel
{
public $id_advmegamenu_item;
public $id_shop;
public $parent_id;
public $position;
public $active;
public $type;
public $entity_id;
public $icon_path;
public $new_window;
public static $definition = [
'table' => 'advmegamenu_item',
'primary' => 'id_advmegamenu_item',
'multilang' => false,
'fields' => [
'id_shop' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt', 'required' => true],
'parent_id' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'],
'position' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'],
'active' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
'type' => ['type' => self::TYPE_STRING, 'validate' => 'isGenericName', 'size' => 32],
'entity_id' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedInt'],
'icon_path' => ['type' => self::TYPE_STRING, 'validate' => 'isString', 'size' => 255],
'new_window' => ['type' => self::TYPE_BOOL, 'validate' => 'isBool'],
],
];
}
+270
View File
@@ -0,0 +1,270 @@
<?php
namespace AdvancedMegaMenu\Repository;
class MenuRepository
{
private \Db $db;
private \Module $module;
public function __construct(\Db $db, \Module $module)
{
$this->db = $db;
$this->module = $module;
}
/**
* @param array<int, array{id_lang:int}> $languages
*
* @return array<int, array<string, mixed>>
*/
public function getAdminTree(int $idShop, array $languages): array
{
$items = $this->loadItems($idShop);
if (empty($items)) {
return [];
}
$itemIds = array_map(static function (array $item): int {
return (int) $item['id_advmegamenu_item'];
}, $items);
$layouts = $this->loadLayouts($itemIds);
$layoutIds = array_map(static function (array $layout): int {
return (int) $layout['id_advmegamenu_layout'];
}, $layouts);
$layoutProducts = $this->loadLayoutProducts($layoutIds);
$translations = $this->loadTranslations($itemIds, $languages);
$nodes = [];
foreach ($items as $item) {
$itemId = (int) $item['id_advmegamenu_item'];
$node = [
'id' => $itemId,
'parent_id' => (int) $item['parent_id'],
'position' => (int) $item['position'],
'active' => (bool) $item['active'],
'type' => $item['type'],
'entity_id' => (int) $item['entity_id'],
'icon_path' => (string) $item['icon_path'],
'icon_url' => $this->buildImageUrl((string) $item['icon_path']),
'new_window' => (bool) $item['new_window'],
'lang' => [],
'layouts' => [],
'children' => [],
];
foreach ($languages as $language) {
$idLang = (int) $language['id_lang'];
$langData = $translations[$itemId][$idLang] ?? [];
$node['lang'][$idLang] = [
'title' => $langData['title'] ?? '',
'description' => $langData['description'] ?? '',
'custom_link' => $langData['custom_link'] ?? '',
];
}
foreach ($layouts as $layout) {
if ((int) $layout['id_advmegamenu_item'] !== $itemId) {
continue;
}
$layoutId = (int) $layout['id_advmegamenu_layout'];
$node['layouts'][] = [
'id' => $layoutId,
'position' => (int) $layout['position'],
'column_width' => (int) $layout['column_width'],
'show_title' => (bool) $layout['show_title'],
'custom_image' => (string) $layout['custom_image'],
'custom_image_url' => $this->buildImageUrl((string) $layout['custom_image']),
'background_color' => (string) $layout['background_color'],
'block_type' => (string) $layout['block_type'],
'products' => $layoutProducts[$layoutId] ?? [],
];
}
$nodes[$itemId] = $node;
}
foreach ($nodes as $nodeId => &$node) {
if ($node['parent_id'] > 0 && isset($nodes[$node['parent_id']])) {
$nodes[$node['parent_id']]['children'][] = &$node;
}
}
unset($node);
$tree = [];
foreach ($nodes as $nodeId => $node) {
if ($node['parent_id'] === 0) {
$tree[] = $this->sortNode($node);
}
}
usort($tree, static function (array $left, array $right): int {
return $left['position'] <=> $right['position'];
});
return $tree;
}
/**
* @return array<int, array<string, mixed>>
*/
public function getRawTree(int $idShop, int $idLang): array
{
$languages = [['id_lang' => $idLang]];
$tree = $this->getAdminTree($idShop, $languages);
return array_map(static function (array $node) use ($idLang): array {
return self::flattenLang($node, $idLang);
}, $tree);
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadItems(int $idShop): array
{
return $this->db->executeS(
'SELECT * FROM `' . _DB_PREFIX_ . 'advmegamenu_item`
WHERE `id_shop` = ' . (int) $idShop . '
ORDER BY `parent_id` ASC, `position` ASC'
) ?: [];
}
/**
* @param array<int, int> $itemIds
*
* @return array<int, array<string, mixed>>
*/
private function loadLayouts(array $itemIds): array
{
if (empty($itemIds)) {
return [];
}
return $this->db->executeS(
'SELECT * FROM `' . _DB_PREFIX_ . 'advmegamenu_layout`
WHERE `id_advmegamenu_item` IN (' . implode(',', array_map('intval', $itemIds)) . ')
ORDER BY `id_advmegamenu_item` ASC, `position` ASC'
) ?: [];
}
/**
* @param array<int, int> $layoutIds
*
* @return array<int, array<int, int>>
*/
private function loadLayoutProducts(array $layoutIds): array
{
if (empty($layoutIds)) {
return [];
}
$rows = $this->db->executeS(
'SELECT `id_advmegamenu_layout`, `id_product`
FROM `' . _DB_PREFIX_ . 'advmegamenu_products`
WHERE `id_advmegamenu_layout` IN (' . implode(',', array_map('intval', $layoutIds)) . ')
ORDER BY `position` ASC, `id_advmegamenu_products` ASC'
) ?: [];
$products = [];
foreach ($rows as $row) {
$products[(int) $row['id_advmegamenu_layout']][] = (int) $row['id_product'];
}
return $products;
}
/**
* @param array<int, int> $itemIds
* @param array<int, array{id_lang:int}> $languages
*
* @return array<int, array<int, array<string, string>>>
*/
private function loadTranslations(array $itemIds, array $languages): array
{
if (empty($itemIds)) {
return [];
}
$langIds = array_map(static function (array $language): int {
return (int) $language['id_lang'];
}, $languages);
$rows = $this->db->executeS(
'SELECT * FROM `' . _DB_PREFIX_ . 'advmegamenu_item_lang`
WHERE `id_advmegamenu_item` IN (' . implode(',', array_map('intval', $itemIds)) . ')
AND `id_lang` IN (' . implode(',', array_map('intval', $langIds)) . ')'
) ?: [];
$translations = [];
foreach ($rows as $row) {
$translations[(int) $row['id_advmegamenu_item']][(int) $row['id_lang']] = [
'title' => (string) $row['title'],
'description' => (string) $row['description'],
'custom_link' => (string) $row['custom_link'],
];
}
return $translations;
}
/**
* @param array<string, mixed> $node
*
* @return array<string, mixed>
*/
private function sortNode(array $node): array
{
usort($node['layouts'], static function (array $left, array $right): int {
return $left['position'] <=> $right['position'];
});
foreach ($node['children'] as &$child) {
$child = $this->sortNode($child);
}
unset($child);
usort($node['children'], static function (array $left, array $right): int {
return $left['position'] <=> $right['position'];
});
return $node;
}
/**
* @param array<string, mixed> $node
*
* @return array<string, mixed>
*/
private static function flattenLang(array $node, int $idLang): array
{
$lang = $node['lang'][$idLang] ?? reset($node['lang']) ?? [
'title' => '',
'description' => '',
'custom_link' => '',
];
$node['title'] = $lang['title'];
$node['description'] = $lang['description'];
$node['custom_link'] = $lang['custom_link'];
unset($node['lang']);
$node['children'] = array_map(static function (array $child) use ($idLang): array {
return self::flattenLang($child, $idLang);
}, $node['children']);
return $node;
}
private function buildImageUrl(string $path): string
{
if ($path === '') {
return '';
}
return rtrim($this->module->getPathUri(), '/') . '/views/img/' . ltrim($path, '/');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
spl_autoload_register(static function ($class) {
$prefix = 'AdvancedMegaMenu\\';
$baseDir = __DIR__ . '/';
if (strncmp($prefix, $class, strlen($prefix)) !== 0) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (is_file($file)) {
require_once $file;
}
});
+112
View File
@@ -0,0 +1,112 @@
.adv-megamenu-admin__toolbar {
margin-bottom: 1rem;
}
.adv-megamenu-admin__tree {
display: grid;
gap: 1rem;
}
.adv-menu-node {
border: 1px solid #d9dee3;
border-radius: 8px;
background: #fff;
}
.adv-menu-node__header,
.adv-menu-layout__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1rem;
background: #f7f8f9;
border-bottom: 1px solid #e7eaee;
}
.adv-menu-node__body,
.adv-menu-layout__body {
padding: 1rem;
}
.adv-menu-node__grid,
.adv-menu-layout__grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.adv-menu-node__actions,
.adv-menu-layout__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.adv-menu-node__children,
.adv-menu-node__layouts {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
.adv-menu-node__children {
padding-left: 1.25rem;
border-left: 2px solid #e7eaee;
}
.adv-menu-language {
border: 1px dashed #d9dee3;
border-radius: 6px;
padding: 0.8rem;
}
.adv-menu-image-preview {
max-width: 180px;
margin-top: 0.6rem;
border-radius: 6px;
}
.adv-menu-products {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.adv-menu-product-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.55rem;
border-radius: 999px;
background: #eef3f7;
}
.adv-menu-product-results {
margin-top: 0.5rem;
border: 1px solid #d9dee3;
border-radius: 6px;
max-height: 180px;
overflow: auto;
}
.adv-menu-product-results button {
display: block;
width: 100%;
padding: 0.55rem 0.75rem;
text-align: left;
border: 0;
background: #fff;
}
.adv-menu-product-results button + button {
border-top: 1px solid #eef2f5;
}
@media (max-width: 991px) {
.adv-menu-node__grid,
.adv-menu-layout__grid {
grid-template-columns: 1fr;
}
}
+583
View File
@@ -0,0 +1,583 @@
.adv-megamenu {
position: relative;
z-index: 500;
color: #2d241d;
display: inline-block;
width: auto;
max-width: 100%;
vertical-align: top;
}
.adv-megamenu__root-list,
.adv-megamenu__category-tree,
.adv-megamenu__manual-tree,
.adv-megamenu__mobile-list {
list-style: none;
margin: 0;
padding: 0;
}
.adv-megamenu__desktop {
position: relative;
display: inline-block;
width: auto;
max-width: 100%;
color: #2d241d;
overflow: visible;
}
.adv-megamenu__root-list {
width: auto;
display: flex;
align-items: stretch;
gap: 0.25rem;
flex-wrap: nowrap;
white-space: nowrap;
padding: 0 0.25rem;
}
.adv-megamenu__root-item {
position: static;
}
.adv-megamenu .adv-megamenu__root-link,
.adv-megamenu .adv-megamenu__root-link:link,
.adv-megamenu .adv-megamenu__root-link:visited {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 58px;
padding: 0 1rem;
color: #2d241d;
font-weight: 600;
text-decoration: none;
background: transparent;
}
.adv-megamenu__root-item.is-current > .adv-megamenu__root-link,
.adv-megamenu__root-item:hover > .adv-megamenu__root-link,
.adv-megamenu__root-item.is-open > .adv-megamenu__root-link {
color: var(--bs-primary);
}
.adv-megamenu__root-item.is-open > .adv-megamenu__root-link {
position: relative;
z-index: 560;
}
.adv-megamenu__panel {
position: absolute;
z-index: 550;
top: calc(100% + 3px);
left: var(--adv-panel-left, 0px);
right: auto;
width: var(--adv-panel-width, min(1440px, calc(100vw - 2rem)));
min-width: var(--adv-panel-width, min(1440px, calc(100vw - 2rem)));
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: opacity 180ms ease, transform 180ms ease, visibility 180ms ease;
}
.adv-megamenu__root-item.is-open > .adv-megamenu__panel {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.adv-megamenu__root-item.is-open > .adv-megamenu__panel::before {
content: '';
position: absolute;
top: -4px;
left: var(--adv-bridge-left, 1rem);
width: var(--adv-bridge-width, 8rem);
height: 4px;
pointer-events: none;
background: var(--bs-primary);
border-radius: 999px;
}
.adv-megamenu__panel-inner {
display: grid;
grid-template-columns: minmax(280px, 320px) 1fr;
gap: 2rem;
padding: 1.5rem;
background: #fffdfa;
color: #2d241d;
border: 1px solid #ddd3c3;
border-top: 0;
box-shadow: 0 18px 42px rgba(45, 36, 29, 0.14);
}
.adv-megamenu__panel-nav {
padding-right: 1rem;
border-right: 1px solid #ebe2d6;
}
.adv-megamenu__manual-tree li {
border-bottom: 1px solid #ebe2d6;
}
.adv-megamenu__manual-tree li.has-submenu > a {
position: relative;
padding-bottom: 0.75rem;
padding-right: 1.5rem;
border-bottom: 3px solid transparent;
font-weight: 600;
}
.adv-megamenu__manual-tree li.has-submenu > a::after {
content: '';
position: absolute;
top: 50%;
right: 0.15rem;
transform: translateY(-50%);
color: var(--bs-primary);
}
.adv-megamenu .adv-megamenu__manual-tree li.is-active > a,
.adv-megamenu .adv-megamenu__manual-tree li.is-active > a:link,
.adv-megamenu .adv-megamenu__manual-tree li.is-active > a:visited {
color: var(--bs-primary);
border-bottom-color: var(--bs-primary);
}
.adv-megamenu__panel-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
white-space: normal;
}
.adv-megamenu__submenu-content {
position: relative;
min-height: 100%;
}
.adv-megamenu__submenu-pane {
display: none;
}
.adv-megamenu__submenu-pane.is-active {
display: block;
}
.adv-megamenu__submenu-title {
margin: 0 0 1rem;
font-size: 1.1rem;
font-weight: 700;
color: #241a13;
}
.adv-megamenu__rich-intro {
display: flex;
align-items: flex-start;
gap: 1.25rem;
margin-bottom: 1.5rem;
padding: 1.25rem;
border-radius: 18px;
background: #f7f1ea;
}
.adv-megamenu__rich-intro-media {
flex: 0 0 220px;
max-width: 220px;
}
.adv-megamenu__rich-intro-media img {
display: block;
width: 100%;
height: auto;
border-radius: 14px;
}
.adv-megamenu__rich-intro-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.85rem;
min-width: 0;
}
.adv-megamenu__rich-intro-title {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
color: #241a13;
}
.adv-megamenu__rich-intro-description {
color: #5a4b3f;
line-height: 1.6;
}
.adv-megamenu__rich-intro-description > :last-child {
margin-bottom: 0;
}
.adv-megamenu .adv-megamenu__rich-intro-link,
.adv-megamenu .adv-megamenu__rich-intro-link:link,
.adv-megamenu .adv-megamenu__rich-intro-link:visited {
display: inline-flex;
align-self: flex-start;
align-items: center;
justify-content: center;
text-decoration: none;
}
.adv-megamenu .adv-megamenu__submenu-pane,
.adv-megamenu .adv-megamenu__submenu-pane a,
.adv-megamenu .adv-megamenu__submenu-pane a:link,
.adv-megamenu .adv-megamenu__submenu-pane a:visited {
color: #2d241d;
}
.adv-megamenu__panel-tree,
.adv-megamenu__panel-tree ul {
list-style: none;
margin: 0;
padding: 0;
}
.adv-megamenu__panel-tree {
display: grid;
gap: 0.5rem;
}
.adv-megamenu__panel-tree ul {
margin-top: 0.45rem;
margin-left: 1rem;
}
.adv-megamenu .adv-megamenu__panel-tree a,
.adv-megamenu .adv-megamenu__panel-tree a:link,
.adv-megamenu .adv-megamenu__panel-tree a:visited {
display: inline-block;
padding: 0.15rem 0;
color: #3d3026;
text-decoration: none;
}
.adv-megamenu .adv-megamenu__panel-tree a:hover {
color: #8a3c1f;
}
.adv-megamenu .adv-megamenu__category-tree a,
.adv-megamenu .adv-megamenu__category-tree a:link,
.adv-megamenu .adv-megamenu__category-tree a:visited,
.adv-megamenu .adv-megamenu__manual-tree a,
.adv-megamenu .adv-megamenu__manual-tree a:link,
.adv-megamenu .adv-megamenu__manual-tree a:visited {
display: block;
padding: 0.35rem 0;
color: #3d3026;
text-decoration: none;
}
.adv-megamenu .adv-megamenu__category-tree a:hover,
.adv-megamenu .adv-megamenu__manual-tree a:hover {
color: #8a3c1f;
}
.adv-megamenu__category-tree ul,
.adv-megamenu__manual-tree ul {
margin-left: 1rem;
}
.adv-megamenu__panel-content .row {
row-gap: 1rem;
}
.adv-megamenu__manual-tree li.has-children > ul,
.adv-megamenu__category-tree li.has-children > ul {
display: none;
}
.adv-megamenu__manual-tree li.is-expanded > ul,
.adv-megamenu__category-tree li.is-expanded > ul {
display: block;
}
.adv-megamenu__manual-tree li.has-children > a,
.adv-megamenu__category-tree li.has-children > a {
cursor: pointer;
}
.adv-megamenu__promo {
height: 100%;
padding: 1rem;
border-radius: 18px;
background: var(--adv-promo-bg, #f3ede5);
color: #2d241d;
}
.adv-megamenu__layout-row {
row-gap: 1rem;
}
.adv-megamenu__layout-card {
overflow: hidden;
border: 0;
box-shadow: 0 10px 24px rgba(45, 36, 29, 0.08);
}
.adv-megamenu__layout-card-body {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.adv-megamenu__layout-card .card-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: #241a13;
}
.adv-megamenu__layout-card .card-text {
margin: 0;
color: #5a4b3f;
line-height: 1.5;
}
.adv-megamenu__promo-media {
margin-bottom: 0.9rem;
overflow: hidden;
}
.adv-megamenu__promo-media img {
display: block;
width: 100%;
height: auto;
}
.adv-megamenu__products {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.adv-megamenu__product-card {
background: rgba(255, 255, 255, 0.82);
color: #2d241d;
}
.adv-megamenu .adv-megamenu__product-link,
.adv-megamenu .adv-megamenu__product-link:link,
.adv-megamenu .adv-megamenu__product-link:visited {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
color: #2d241d;
text-decoration: none;
}
.adv-megamenu__product-link img {
width: 100%;
height: auto;
border-radius: 10px;
}
.adv-megamenu__product-name {
font-size: 0.9rem;
line-height: 1.35;
}
.adv-megamenu__product-price {
font-weight: 700;
color: #8a3c1f;
}
.adv-megamenu__mobile {
display: none;
}
.adv-megamenu__toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.8rem 1rem;
border: 0;
background: #f3efe8;
color: #2d241d;
font-weight: 700;
}
.adv-megamenu__overlay {
position: fixed;
inset: 0;
z-index: 2000;
display: none;
background: rgba(30, 22, 17, 0.4);
}
.adv-megamenu__overlay.is-open {
display: block;
}
.adv-megamenu__drawer {
width: min(88vw, 380px);
height: 100%;
background: #fffdfa;
box-shadow: 20px 0 50px rgba(0, 0, 0, 0.16);
transform: translateX(-100%);
transition: transform 220ms ease;
}
.adv-megamenu__overlay.is-open .adv-megamenu__drawer {
transform: translateX(0);
}
.adv-megamenu__mobile-head,
.adv-megamenu__mobile-bar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 56px;
padding: 0 1rem;
border-bottom: 1px solid #ebe2d6;
color: #2d241d;
background: #fffdfa;
}
.adv-megamenu__close,
.adv-megamenu__mobile-back,
.adv-megamenu__mobile-link-row button {
border: 0;
background: transparent;
color: #8a3c1f;
font-weight: 700;
}
.adv-megamenu__mobile-viewport {
position: relative;
height: calc(100% - 56px);
overflow: hidden;
}
.adv-megamenu__mobile-panel {
position: absolute;
inset: 0;
overflow-y: auto;
background: #fffdfa;
transform: translateX(100%);
transition: transform 220ms ease;
}
.adv-megamenu__mobile-panel.is-active {
transform: translateX(0);
}
.adv-megamenu__mobile-panel.is-left {
transform: translateX(-100%);
}
.adv-megamenu__mobile-link-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.5rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid #f0e9df;
background: #fffdfa;
}
.adv-megamenu .adv-megamenu__mobile-link-row a,
.adv-megamenu .adv-megamenu__mobile-link-row a:link,
.adv-megamenu .adv-megamenu__mobile-link-row a:visited {
color: #2d241d;
text-decoration: none;
}
@media (max-width: 991px) {
.adv-megamenu {
display: block;
width: 100%;
}
.adv-megamenu__desktop {
display: none;
}
.adv-megamenu__mobile {
display: block;
}
.adv-megamenu__rich-intro {
flex-direction: column;
}
.adv-megamenu__rich-intro-media {
flex-basis: auto;
max-width: 100%;
}
}
.adv-megamenu__node-card-row {
row-gap: 1rem;
}
.adv-megamenu__node-card {
height: 100%;
border: 1px solid #ebe2d6;
box-shadow: 0 10px 24px rgba(45, 36, 29, 0.08);
background: #fffdfa;
}
.adv-megamenu__node-card .card-img-top {
display: block;
width: 250px;
height: 250px;
max-width: 100%;
margin: 0 auto;
object-fit: contain;
background: #ffffff;
}
.adv-megamenu__node-card .card-body {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.adv-megamenu__node-card .card-title,
.adv-megamenu__node-card .card-text {
color: #2d241d;
}
.adv-megamenu__node-card .card-title {
margin: 0;
font-size: 1rem;
font-weight: 700;
text-align: center;
}
.adv-megamenu .adv-megamenu__node-card .card-title a,
.adv-megamenu .adv-megamenu__node-card .card-title a:link,
.adv-megamenu .adv-megamenu__node-card .card-title a:visited {
color: #2d241d;
text-decoration: none;
}
.adv-megamenu .adv-megamenu__node-card .card-title a:hover {
color: #8a3c1f;
}
.adv-megamenu__node-card .card-text {
margin: 0;
line-height: 1.45;
}
.adv-megamenu__submenu-pane,
.adv-megamenu__panel-tree,
.adv-megamenu__layout-card,
.adv-megamenu__node-card,
.adv-megamenu__node-card .card-title a,
.adv-megamenu__node-card .card-text,
.adv-megamenu__layout-card .card-title,
.adv-megamenu__layout-card .card-text {
white-space: normal;
}
+426
View File
@@ -0,0 +1,426 @@
(function () {
function boot() {
var root = document.getElementById('adv-megamenu-admin');
if (!root) {
return;
}
var treeContainer = root.querySelector('.js-tree');
var treeInput = document.getElementById('advmegamenu_tree_json');
var ajaxUrl = root.getAttribute('data-ajax-url');
var token = root.getAttribute('data-token');
var languages = JSON.parse((root.querySelector('.js-adv-languages-data') || {}).textContent || '[]');
var tree = JSON.parse((root.querySelector('.js-adv-tree-data') || {}).textContent || '[]');
var tempId = Date.now();
function nextId() {
tempId += 1;
return tempId;
}
function createEmptyNode() {
var lang = {};
languages.forEach(function (language) {
lang[language.id_lang] = {
title: '',
description: '',
custom_link: ''
};
});
return {
id: nextId(),
active: true,
type: 'category',
entity_id: 0,
icon_path: '',
icon_url: '',
new_window: false,
lang: lang,
layouts: [],
children: []
};
}
function createEmptyLayout() {
return {
id: nextId(),
column_width: 4,
show_title: true,
custom_image: '',
custom_image_url: '',
background_color: '',
block_type: 'promo',
products: []
};
}
function serializeTree() {
treeInput.value = JSON.stringify(tree);
}
function render() {
serializeTree();
treeContainer.innerHTML = '';
tree.forEach(function (node, index) {
treeContainer.appendChild(renderNode(node, tree, index, 0));
});
}
function move(collection, index, direction) {
var target = direction === 'up' ? index - 1 : index + 1;
if (target < 0 || target >= collection.length) {
return;
}
var current = collection[index];
collection[index] = collection[target];
collection[target] = current;
render();
}
function remove(collection, index) {
collection.splice(index, 1);
render();
}
function renderNode(node, collection, index, depth) {
var wrapper = document.createElement('section');
wrapper.className = 'adv-menu-node';
var title = getNodeTitle(node);
wrapper.innerHTML = '' +
'<div class="adv-menu-node__header">' +
' <strong>' + escapeHtml(title || 'Menu item') + '</strong>' +
' <div class="adv-menu-node__actions">' +
' <button type="button" class="btn btn-default btn-xs js-move-up">↑</button>' +
' <button type="button" class="btn btn-default btn-xs js-move-down">↓</button>' +
' <button type="button" class="btn btn-default btn-xs js-add-child">+ child</button>' +
' <button type="button" class="btn btn-default btn-xs js-add-layout">+ block</button>' +
' <button type="button" class="btn btn-danger btn-xs js-remove-node">×</button>' +
' </div>' +
'</div>' +
'<div class="adv-menu-node__body">' +
' <div class="adv-menu-node__grid">' +
' <label><span>Type</span><select class="form-control js-node-type">' +
' <option value="category">category</option>' +
' <option value="cms">cms</option>' +
' <option value="link">link</option>' +
' <option value="rich_content">rich_content</option>' +
' </select></label>' +
' <label><span>Entity ID</span><input type="number" class="form-control js-entity-id" min="0"></label>' +
' <label><span>Active</span><input type="checkbox" class="js-active"></label>' +
' <label><span>New window</span><input type="checkbox" class="js-new-window"></label>' +
' <label><span>Icon upload</span><input type="file" class="js-icon-upload" accept="image/*"><input type="hidden" class="js-icon-path"></label>' +
' <div class="js-icon-preview-wrap"></div>' +
' </div>' +
' <div class="adv-menu-node__languages"></div>' +
' <div class="adv-menu-node__layouts"></div>' +
' <div class="adv-menu-node__children"></div>' +
'</div>';
wrapper.querySelector('.js-node-type').value = node.type || 'category';
wrapper.querySelector('.js-entity-id').value = node.entity_id || 0;
wrapper.querySelector('.js-active').checked = !!node.active;
wrapper.querySelector('.js-new-window').checked = !!node.new_window;
wrapper.querySelector('.js-icon-path').value = node.icon_path || '';
var iconPreviewWrap = wrapper.querySelector('.js-icon-preview-wrap');
renderImagePreview(iconPreviewWrap, node.icon_url);
wrapper.querySelector('.js-node-type').addEventListener('change', function (event) {
node.type = event.target.value;
render();
});
wrapper.querySelector('.js-entity-id').addEventListener('input', function (event) {
node.entity_id = parseInt(event.target.value || '0', 10);
serializeTree();
});
wrapper.querySelector('.js-active').addEventListener('change', function (event) {
node.active = event.target.checked;
serializeTree();
});
wrapper.querySelector('.js-new-window').addEventListener('change', function (event) {
node.new_window = event.target.checked;
serializeTree();
});
wrapper.querySelector('.js-icon-upload').addEventListener('change', function (event) {
uploadImage(event.target.files[0], 'menu_icon', function (response) {
node.icon_path = response.path;
node.icon_url = response.url;
serializeTree();
renderImagePreview(iconPreviewWrap, response.url);
});
});
wrapper.querySelector('.js-move-up').addEventListener('click', function () {
move(collection, index, 'up');
});
wrapper.querySelector('.js-move-down').addEventListener('click', function () {
move(collection, index, 'down');
});
wrapper.querySelector('.js-remove-node').addEventListener('click', function () {
remove(collection, index);
});
wrapper.querySelector('.js-add-child').addEventListener('click', function () {
node.children = node.children || [];
node.children.push(createEmptyNode());
render();
});
wrapper.querySelector('.js-add-layout').addEventListener('click', function () {
node.layouts = node.layouts || [];
node.layouts.push(createEmptyLayout());
render();
});
var langWrap = wrapper.querySelector('.adv-menu-node__languages');
languages.forEach(function (language) {
var langId = String(language.id_lang);
if (!node.lang[langId]) {
node.lang[langId] = { title: '', description: '', custom_link: '' };
}
var box = document.createElement('div');
box.className = 'adv-menu-language';
box.innerHTML = '' +
'<h4>' + escapeHtml(language.iso_code + ' / ' + language.name) + '</h4>' +
'<label><span>Title</span><input type="text" class="form-control js-lang-title"></label>' +
'<label><span>Description</span><textarea class="form-control js-lang-description" rows="3"></textarea></label>' +
'<label><span>Custom link</span><input type="text" class="form-control js-lang-link"></label>';
box.querySelector('.js-lang-title').value = node.lang[langId].title || '';
box.querySelector('.js-lang-description').value = node.lang[langId].description || '';
box.querySelector('.js-lang-link').value = node.lang[langId].custom_link || '';
box.querySelector('.js-lang-title').addEventListener('input', function (event) {
node.lang[langId].title = event.target.value;
serializeTree();
});
box.querySelector('.js-lang-description').addEventListener('input', function (event) {
node.lang[langId].description = event.target.value;
serializeTree();
});
box.querySelector('.js-lang-link').addEventListener('input', function (event) {
node.lang[langId].custom_link = event.target.value;
serializeTree();
});
langWrap.appendChild(box);
});
var layoutsWrap = wrapper.querySelector('.adv-menu-node__layouts');
(node.layouts || []).forEach(function (layout, layoutIndex) {
layoutsWrap.appendChild(renderLayout(node.layouts, layout, layoutIndex));
});
var childrenWrap = wrapper.querySelector('.adv-menu-node__children');
(node.children || []).forEach(function (child, childIndex) {
childrenWrap.appendChild(renderNode(child, node.children, childIndex, depth + 1));
});
return wrapper;
}
function renderLayout(layouts, layout, index) {
var wrapper = document.createElement('section');
wrapper.className = 'adv-menu-layout';
wrapper.innerHTML = '' +
'<div class="adv-menu-layout__header">' +
' <strong>Rich block</strong>' +
' <div class="adv-menu-layout__actions">' +
' <button type="button" class="btn btn-default btn-xs js-layout-up">↑</button>' +
' <button type="button" class="btn btn-default btn-xs js-layout-down">↓</button>' +
' <button type="button" class="btn btn-danger btn-xs js-remove-layout">×</button>' +
' </div>' +
'</div>' +
'<div class="adv-menu-layout__body">' +
' <div class="adv-menu-layout__grid">' +
' <label><span>Width</span><input type="number" class="form-control js-layout-width" min="1" max="12"></label>' +
' <label><span>Block type</span><select class="form-control js-layout-type"><option value="promo">promo</option><option value="products">products</option></select></label>' +
' <label><span>Show title</span><input type="checkbox" class="js-layout-title"></label>' +
' <label><span>Background color</span><input type="text" class="form-control js-layout-bg" placeholder="#f3efe8"></label>' +
' <label><span>Image upload</span><input type="file" class="js-layout-upload" accept="image/*"></label>' +
' <div class="js-layout-preview-wrap"></div>' +
' </div>' +
' <label><span>Product search</span><input type="text" class="form-control js-product-search" placeholder="Search product by name, reference or ID"></label>' +
' <div class="adv-menu-product-results js-product-results"></div>' +
' <div class="adv-menu-products js-products"></div>' +
'</div>';
wrapper.querySelector('.js-layout-width').value = layout.column_width || 4;
wrapper.querySelector('.js-layout-type').value = layout.block_type || 'promo';
wrapper.querySelector('.js-layout-title').checked = !!layout.show_title;
wrapper.querySelector('.js-layout-bg').value = layout.background_color || '';
var previewWrap = wrapper.querySelector('.js-layout-preview-wrap');
renderImagePreview(previewWrap, layout.custom_image_url);
wrapper.querySelector('.js-layout-width').addEventListener('input', function (event) {
layout.column_width = parseInt(event.target.value || '4', 10);
serializeTree();
});
wrapper.querySelector('.js-layout-type').addEventListener('change', function (event) {
layout.block_type = event.target.value;
serializeTree();
});
wrapper.querySelector('.js-layout-title').addEventListener('change', function (event) {
layout.show_title = event.target.checked;
serializeTree();
});
wrapper.querySelector('.js-layout-bg').addEventListener('input', function (event) {
layout.background_color = event.target.value;
serializeTree();
});
wrapper.querySelector('.js-layout-upload').addEventListener('change', function (event) {
uploadImage(event.target.files[0], 'layout_image', function (response) {
layout.custom_image = response.path;
layout.custom_image_url = response.url;
serializeTree();
renderImagePreview(previewWrap, response.url);
});
});
wrapper.querySelector('.js-layout-up').addEventListener('click', function () {
move(layouts, index, 'up');
});
wrapper.querySelector('.js-layout-down').addEventListener('click', function () {
move(layouts, index, 'down');
});
wrapper.querySelector('.js-remove-layout').addEventListener('click', function () {
remove(layouts, index);
});
var productsWrap = wrapper.querySelector('.js-products');
renderProducts(layout.products || [], productsWrap, function (nextProducts) {
layout.products = nextProducts;
serializeTree();
});
var resultsWrap = wrapper.querySelector('.js-product-results');
var searchInput = wrapper.querySelector('.js-product-search');
var searchTimer = null;
searchInput.addEventListener('input', function () {
clearTimeout(searchTimer);
var value = searchInput.value.trim();
if (value.length < 2) {
resultsWrap.innerHTML = '';
return;
}
searchTimer = window.setTimeout(function () {
fetch(ajaxUrl + '&ajax=1&action=searchProducts&q=' + encodeURIComponent(value))
.then(function (response) { return response.json(); })
.then(function (payload) {
resultsWrap.innerHTML = '';
(payload.results || []).forEach(function (result) {
var button = document.createElement('button');
button.type = 'button';
button.textContent = result.label;
button.addEventListener('click', function () {
layout.products = layout.products || [];
if (layout.products.indexOf(result.id) === -1) {
layout.products.push(result.id);
}
render();
});
resultsWrap.appendChild(button);
});
});
}, 200);
});
return wrapper;
}
function renderProducts(products, wrap, onChange) {
wrap.innerHTML = '';
products.forEach(function (productId, index) {
var chip = document.createElement('span');
chip.className = 'adv-menu-product-chip';
chip.innerHTML = '<span>#' + escapeHtml(String(productId)) + '</span><button type="button">×</button>';
chip.querySelector('button').addEventListener('click', function () {
var nextProducts = products.slice();
nextProducts.splice(index, 1);
onChange(nextProducts);
render();
});
wrap.appendChild(chip);
});
}
function renderImagePreview(wrap, url) {
wrap.innerHTML = '';
if (!url) {
return;
}
var image = document.createElement('img');
image.src = url;
image.className = 'adv-menu-image-preview';
wrap.appendChild(image);
}
function getNodeTitle(node) {
var firstLang = languages[0] ? String(languages[0].id_lang) : null;
if (firstLang && node.lang[firstLang] && node.lang[firstLang].title) {
return node.lang[firstLang].title;
}
var langIds = Object.keys(node.lang || {});
if (langIds.length && node.lang[langIds[0]]) {
return node.lang[langIds[0]].title || '';
}
return '';
}
function uploadImage(file, preset, callback) {
if (!file) {
return;
}
var formData = new FormData();
formData.append('ajax', '1');
formData.append('action', 'uploadImage');
formData.append('token', token);
formData.append('preset', preset || 'default');
formData.append('image', file);
fetch(ajaxUrl, {
method: 'POST',
body: formData
})
.then(function (response) { return response.json(); })
.then(function (payload) {
if (payload && !payload.error) {
callback(payload);
}
});
}
function escapeHtml(value) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
var addRootButton = root.querySelector('.js-add-root-node');
if (addRootButton) {
addRootButton.addEventListener('click', function () {
tree.push(createEmptyNode());
render();
});
}
render();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();
+357
View File
@@ -0,0 +1,357 @@
(function () {
var menuRoot = document.querySelector('.adv-megamenu');
if (!menuRoot) {
return;
}
var hoverDelay = 120;
var clickDelay = 220;
var hoverTimers = new WeakMap();
var clickTimers = new WeakMap();
function isDesktop() {
return window.matchMedia('(min-width: 992px)').matches;
}
function navigateToLink(link) {
var href = link.getAttribute('href');
if (!href || href === '#') {
return;
}
if (link.getAttribute('target') === '_blank') {
window.open(href, '_blank', 'noopener');
return;
}
window.location.href = href;
}
function closeUnlockedPanels(exceptItem) {
menuRoot.querySelectorAll('.adv-megamenu__root-item.is-open').forEach(function (item) {
if (item === exceptItem || item.classList.contains('is-click-open')) {
return;
}
item.classList.remove('is-open');
});
}
function closeAllPanels(exceptItem) {
menuRoot.querySelectorAll('.adv-megamenu__root-item.is-open, .adv-megamenu__root-item.is-click-open').forEach(function (item) {
if (item === exceptItem) {
return;
}
item.classList.remove('is-open', 'is-click-open');
});
}
function getPageContainer() {
return document.querySelector('.header-bottom__container') || document.querySelector('.container-md') || document.querySelector('.container');
}
function positionDesktopPanels() {
if (!isDesktop()) {
return;
}
var container = getPageContainer();
if (!container) {
return;
}
var containerRect = container.getBoundingClientRect();
var desktopRect = menuRoot.querySelector('.adv-megamenu__desktop').getBoundingClientRect();
var viewportWidth = window.innerWidth || document.documentElement.clientWidth;
var panelWidth = Math.min(1440, viewportWidth - 32, containerRect.width);
var left = containerRect.left - desktopRect.left;
menuRoot.style.setProperty('--adv-panel-left', left + 'px');
menuRoot.style.setProperty('--adv-panel-width', panelWidth + 'px');
}
function positionBridgeForItem(item) {
if (!isDesktop()) {
return;
}
var desktop = menuRoot.querySelector('.adv-megamenu__desktop');
var link = item.querySelector(':scope > .adv-megamenu__root-link');
if (!desktop || !link) {
return;
}
var panelLeft = parseFloat(getComputedStyle(menuRoot).getPropertyValue('--adv-panel-left')) || 0;
var desktopRect = desktop.getBoundingClientRect();
var linkRect = link.getBoundingClientRect();
var preferredWidth = Math.max(40, linkRect.width - 24);
var bridgeLeft = linkRect.left - desktopRect.left - panelLeft + ((linkRect.width - preferredWidth) / 2);
item.style.setProperty('--adv-bridge-left', bridgeLeft + 'px');
item.style.setProperty('--adv-bridge-width', preferredWidth + 'px');
}
function activateSubmenuPane(item) {
if (!item) {
return;
}
var panel = item.closest('.adv-megamenu__panel');
var key = item.getAttribute('data-submenu-key');
if (!panel || !key) {
return;
}
panel.querySelectorAll('.adv-megamenu__manual-tree li.is-active').forEach(function (entry) {
entry.classList.remove('is-active');
});
panel.querySelectorAll('.adv-megamenu__submenu-pane.is-active').forEach(function (pane) {
pane.classList.remove('is-active');
});
item.classList.add('is-active');
var targetPane = panel.querySelector('[data-submenu-pane="' + key + '"]');
if (targetPane) {
targetPane.classList.add('is-active');
}
}
function activateFirstSubmenuPane(rootItem) {
if (!rootItem) {
return;
}
var firstItem = rootItem.querySelector('.adv-megamenu__manual-tree li.has-submenu');
if (firstItem) {
activateSubmenuPane(firstItem);
}
}
function bindDesktopClickIntent(link, onSingleClick) {
link.addEventListener('click', function (event) {
if (!isDesktop()) {
return;
}
event.preventDefault();
var existingTimer = clickTimers.get(link);
if (existingTimer) {
window.clearTimeout(existingTimer);
clickTimers.delete(link);
navigateToLink(link);
return;
}
var timer = window.setTimeout(function () {
clickTimers.delete(link);
onSingleClick();
}, clickDelay);
clickTimers.set(link, timer);
});
}
menuRoot.querySelectorAll('.adv-megamenu__root-item.has-panel').forEach(function (item) {
var link = item.querySelector(':scope > .adv-megamenu__root-link');
item.addEventListener('mouseenter', function () {
if (!isDesktop()) {
return;
}
window.clearTimeout(hoverTimers.get(item));
var timer = window.setTimeout(function () {
closeUnlockedPanels(item);
positionBridgeForItem(item);
activateFirstSubmenuPane(item);
item.classList.add('is-open');
}, hoverDelay);
hoverTimers.set(item, timer);
});
item.addEventListener('mouseleave', function () {
if (!isDesktop()) {
return;
}
window.clearTimeout(hoverTimers.get(item));
var timer = window.setTimeout(function () {
if (!item.classList.contains('is-click-open')) {
item.classList.remove('is-open');
}
}, hoverDelay);
hoverTimers.set(item, timer);
});
if (!link) {
return;
}
bindDesktopClickIntent(link, function () {
var isOpen = item.classList.contains('is-open');
var isPinned = item.classList.contains('is-click-open');
if (!isOpen) {
closeAllPanels(item);
positionBridgeForItem(item);
activateFirstSubmenuPane(item);
item.classList.add('is-open', 'is-click-open');
return;
}
if (!isPinned) {
closeAllPanels(item);
positionBridgeForItem(item);
activateFirstSubmenuPane(item);
item.classList.add('is-click-open');
return;
}
item.classList.remove('is-click-open', 'is-open');
});
});
menuRoot.querySelectorAll('.adv-megamenu__manual-tree li.has-submenu').forEach(function (item) {
var link = item.querySelector(':scope > .adv-megamenu__submenu-trigger');
if (!link) {
return;
}
item.addEventListener('mouseenter', function () {
if (!isDesktop()) {
return;
}
activateSubmenuPane(item);
});
bindDesktopClickIntent(link, function () {
activateSubmenuPane(item);
});
});
menuRoot.querySelectorAll('.adv-megamenu__manual-tree li, .adv-megamenu__category-tree li').forEach(function (item) {
var directLink = item.querySelector(':scope > a');
var directSubtree = item.querySelector(':scope > ul');
if (!directLink || !directSubtree) {
return;
}
item.classList.add('has-children');
bindDesktopClickIntent(directLink, function () {
item.classList.toggle('is-expanded');
});
});
var overlay = menuRoot.querySelector('.js-adv-overlay');
var openButton = menuRoot.querySelector('.js-adv-menu-toggle');
var closeButton = menuRoot.querySelector('.js-adv-menu-close');
var panelStack = ['root'];
function setActivePanel(panelId) {
var panels = menuRoot.querySelectorAll('.adv-megamenu__mobile-panel');
panels.forEach(function (panel) {
var currentId = panel.getAttribute('data-panel-id');
panel.classList.remove('is-active', 'is-left');
var currentIndex = panelStack.indexOf(currentId);
if (currentIndex === -1) {
return;
}
if (currentId === panelId) {
panel.classList.add('is-active');
} else if (currentIndex < panelStack.length - 1) {
panel.classList.add('is-left');
}
});
}
function openDrawer() {
if (!overlay) {
return;
}
overlay.classList.add('is-open');
if (openButton) {
openButton.setAttribute('aria-expanded', 'true');
}
panelStack = ['root'];
setActivePanel('root');
}
function closeDrawer() {
if (!overlay) {
return;
}
overlay.classList.remove('is-open');
if (openButton) {
openButton.setAttribute('aria-expanded', 'false');
}
}
if (openButton) {
openButton.addEventListener('click', openDrawer);
}
if (closeButton) {
closeButton.addEventListener('click', closeDrawer);
}
if (overlay) {
overlay.addEventListener('click', function (event) {
if (event.target === overlay) {
closeDrawer();
}
});
}
document.addEventListener('click', function (event) {
if (!isDesktop()) {
return;
}
if (menuRoot.contains(event.target)) {
return;
}
closeAllPanels();
menuRoot.querySelectorAll('.adv-megamenu__manual-tree li.is-expanded, .adv-megamenu__category-tree li.is-expanded').forEach(function (item) {
item.classList.remove('is-expanded');
});
});
menuRoot.querySelectorAll('.js-adv-open-panel').forEach(function (button) {
button.addEventListener('click', function () {
var target = button.getAttribute('data-target');
if (!target) {
return;
}
panelStack.push(target);
setActivePanel(target);
});
});
menuRoot.querySelectorAll('.js-adv-back').forEach(function (button) {
button.addEventListener('click', function () {
if (panelStack.length <= 1) {
return;
}
panelStack.pop();
setActivePanel(panelStack[panelStack.length - 1]);
});
});
positionDesktopPanels();
window.addEventListener('resize', function () {
positionDesktopPanels();
menuRoot.querySelectorAll('.adv-megamenu__root-item.is-open').forEach(positionBridgeForItem);
});
})();
+42
View File
@@ -0,0 +1,42 @@
<div
id="adv-megamenu-admin"
class="adv-megamenu-admin"
data-default-lang="{$advmegamenu_default_lang|intval}"
data-ajax-url="{$advmegamenu_ajax_url|escape:'htmlall':'UTF-8'}"
data-token="{$advmegamenu_admin_token|escape:'htmlall':'UTF-8'}"
>
<script type="application/json" class="js-adv-tree-data">{$advmegamenu_tree|json_encode nofilter}</script>
<script type="application/json" class="js-adv-languages-data">{$advmegamenu_languages|json_encode nofilter}</script>
<form method="post" action="" class="defaultForm form-horizontal">
<input type="hidden" name="advmegamenu_tree_json" id="advmegamenu_tree_json" value="" />
<div class="panel">
<div class="panel-heading">
<i class="icon-sitemap"></i>
{l s='Mega menu builder' d='Modules.Advancedmegamenu.Admin'}
</div>
<p class="help-block">
{l s='Build the navigation tree, attach promo blocks and products, then save to regenerate the sprite and front cache.' d='Modules.Advancedmegamenu.Admin'}
</p>
<div class="adv-megamenu-admin__toolbar">
<button type="button" class="btn btn-default js-add-root-node">
<i class="icon-plus"></i>
{l s='Add root item' d='Modules.Advancedmegamenu.Admin'}
</button>
</div>
<div class="adv-megamenu-admin__tree js-tree"></div>
</div>
<div class="panel">
<div class="panel-footer">
<button type="submit" name="submitAdvancedMegaMenu" class="btn btn-primary pull-right">
<i class="process-icon-save"></i>
{l s='Save mega menu' d='Modules.Advancedmegamenu.Admin'}
</button>
</div>
</div>
</form>
</div>
+286
View File
@@ -0,0 +1,286 @@
{function name=advCategoryTree nodes=[]}
{if $nodes|count}
<ul class="adv-megamenu__category-tree">
{foreach from=$nodes item=categoryNode}
<li>
<a href="{$categoryNode.url|escape:'htmlall':'UTF-8'}">{$categoryNode.title|escape:'htmlall':'UTF-8'}</a>
{if $categoryNode.children|count}
{advCategoryTree nodes=$categoryNode.children}
{/if}
</li>
{/foreach}
</ul>
{/if}
{/function}
{function name=advPanelTree nodes=[]}
{if $nodes|count}
<ul class="adv-megamenu__panel-tree">
{foreach from=$nodes item=treeNode}
<li{if $treeNode.children|count} class="has-children"{/if}>
<a href="{$treeNode.url|escape:'htmlall':'UTF-8'}">{$treeNode.title|escape:'htmlall':'UTF-8'}</a>
{if $treeNode.children|count}
{advPanelTree nodes=$treeNode.children}
{/if}
</li>
{/foreach}
</ul>
{/if}
{/function}
{function name=advNodeCards nodes=[]}
{if $nodes|count}
<div class="row adv-megamenu__node-card-row">
{foreach from=$nodes item=treeNode}
<div class="col-xl-3 col-lg-4 col-sm-6">
<article class="card adv-megamenu__node-card">
{if $treeNode.icon_url}
<img src="{$treeNode.icon_url|escape:'htmlall':'UTF-8'}" class="card-img-top" alt="{$treeNode.title|escape:'htmlall':'UTF-8'}" loading="lazy">
{/if}
<div class="card-body">
<h3 class="card-title">
<a href="{$treeNode.url|escape:'htmlall':'UTF-8'}">{$treeNode.title|escape:'htmlall':'UTF-8'}</a>
</h3>
{if $treeNode.description}
<p class="card-text">{$treeNode.description|escape:'htmlall':'UTF-8'}</p>
{/if}
</div>
</article>
</div>
{/foreach}
</div>
{/if}
{/function}
{function name=advNodeCardsByType nodes=[] type=''}
{assign var=matchingNodes value=[]}
{foreach from=$nodes item=treeNode}
{if $treeNode.type == $type}
{append var='matchingNodes' value=$treeNode}
{/if}
{/foreach}
{if $matchingNodes|count}
{advNodeCards nodes=$matchingNodes}
{/if}
{/function}
{function name=advRichContentIntro node=[]}
{if $node.type == 'rich_content'}
<article class="adv-megamenu__rich-intro">
{if $node.icon_url}
<div class="adv-megamenu__rich-intro-media">
<img src="{$node.icon_url|escape:'htmlall':'UTF-8'}" alt="{$node.title|escape:'htmlall':'UTF-8'}" loading="lazy">
</div>
{/if}
<div class="adv-megamenu__rich-intro-body">
{if $node.title}
<h3 class="adv-megamenu__rich-intro-title">{$node.title|escape:'htmlall':'UTF-8'}</h3>
{/if}
{if $node.description}
<div class="adv-megamenu__rich-intro-description">{$node.description|escape:'htmlall':'UTF-8'}</div>
{/if}
{if $node.custom_link}
<a href="{$node.custom_link|escape:'htmlall':'UTF-8'}" class="adv-megamenu__rich-intro-link btn btn-primary" {if $node.new_window}target="_blank" rel="noopener"{/if}>
{l s='Zobacz wszystko' d='Modules.Advancedmegamenu.Shop'}
</a>
{/if}
</div>
</article>
{/if}
{/function}
{function name=advLayoutCards layouts=[]}
{if $layouts|count}
<div class="row adv-megamenu__layout-row">
{foreach from=$layouts item=layout}
<div class="col-md-{$layout.column_width|intval}">
<article class="card adv-megamenu__layout-card adv-megamenu__promo{if $layout.block_type == 'products'} is-products-only{/if}"{if $layout.background_color} style="--adv-promo-bg: {$layout.background_color|escape:'htmlall':'UTF-8'};"{/if}>
{if $layout.custom_image_url}
<div class="adv-megamenu__promo-media card-img-top">
<img src="{$layout.custom_image_url|escape:'htmlall':'UTF-8'}" alt="{$layout.title|escape:'htmlall':'UTF-8'}" loading="lazy">
</div>
{/if}
<div class="card-body adv-megamenu__layout-card-body">
{if $layout.show_title}
<h3 class="card-title">{$layout.title|escape:'htmlall':'UTF-8'}</h3>
{/if}
{if $layout.description && $layout.block_type != 'products'}
<p class="card-text">{$layout.description|escape:'htmlall':'UTF-8'}</p>
{/if}
{if $layout.products|count}
<div class="adv-megamenu__products">
{foreach from=$layout.products item=product}
<article class="card adv-megamenu__product-card">
<a href="{$product.url|escape:'htmlall':'UTF-8'}" class="adv-megamenu__product-link">
{if !empty($product.cover.bySize.home_default.url)}
<img src="{$product.cover.bySize.home_default.url|escape:'htmlall':'UTF-8'}" alt="{$product.name|escape:'htmlall':'UTF-8'}" loading="lazy">
{/if}
<span class="adv-megamenu__product-name">{$product.name|escape:'htmlall':'UTF-8'}</span>
{if !empty($product.price)}
<span class="adv-megamenu__product-price">{$product.price}</span>
{/if}
</a>
</article>
{/foreach}
</div>
{/if}
</div>
</article>
</div>
{/foreach}
</div>
{/if}
{/function}
{function name=advMobilePanels nodes=[]}
{foreach from=$nodes item=node}
{assign var=panelId value="adv-panel-"|cat:$node.id}
{if $node.children|count || $node.category_branch|count || $node.layouts|count || $node.type == 'rich_content'}
<section class="adv-megamenu__mobile-panel" data-panel-id="{$panelId}">
<div class="adv-megamenu__mobile-bar">
<button type="button" class="adv-megamenu__mobile-back js-adv-back">
&lt; {l s='Wstecz' d='Modules.Advancedmegamenu.Shop'}
</button>
<span>{$node.title|escape:'htmlall':'UTF-8'}</span>
</div>
<ul class="adv-megamenu__mobile-list">
{foreach from=$node.category_branch item=branch}
<li>
<a href="{$branch.url|escape:'htmlall':'UTF-8'}">{$branch.title|escape:'htmlall':'UTF-8'}</a>
</li>
{/foreach}
{foreach from=$node.children item=child}
<li>
<div class="adv-megamenu__mobile-link-row">
<a href="{$child.url|escape:'htmlall':'UTF-8'}" {if $child.new_window}target="_blank" rel="noopener"{/if}>{$child.title|escape:'htmlall':'UTF-8'}</a>
{if $child.children|count || $child.category_branch|count || $child.layouts|count || $child.type == 'rich_content'}
<button type="button" class="js-adv-open-panel" data-target="adv-panel-{$child.id}"></button>
{/if}
</div>
</li>
{/foreach}
</ul>
</section>
{advMobilePanels nodes=$node.children}
{/if}
{/foreach}
{/function}
<nav class="adv-megamenu" aria-label="{l s='Main navigation' d='Modules.Advancedmegamenu.Shop'}">
<div class="adv-megamenu__desktop hidden-sm-down">
<ul class="adv-megamenu__root-list">
{foreach from=$menu.children item=node}
{if $node.active}
<li class="adv-megamenu__root-item{if $node.current} is-current{/if}{if $node.children|count || $node.layouts|count || $node.category_branch|count} has-panel{/if}">
<a href="{$node.url|escape:'htmlall':'UTF-8'}" class="adv-megamenu__root-link" {if $node.new_window}target="_blank" rel="noopener"{/if}>
{if $node.icon_class}
<span class="{$node.icon_class|escape:'htmlall':'UTF-8'}" aria-hidden="true"></span>
{/if}
<span>{$node.title|escape:'htmlall':'UTF-8'}</span>
</a>
{if $node.children|count || $node.layouts|count || $node.category_branch|count}
<div class="adv-megamenu__panel">
<div class="adv-megamenu__panel-inner">
<div class="adv-megamenu__panel-nav">
{if $node.category_branch|count}
{advCategoryTree nodes=$node.category_branch}
{/if}
{if $node.children|count}
<ul class="adv-megamenu__manual-tree">
{foreach from=$node.children item=child}
<li class="{if $child.children|count || $child.category_branch|count || $child.layouts|count || $child.type == 'rich_content'}has-submenu{/if}" data-submenu-key="node-{$child.id}">
<a href="{$child.url|escape:'htmlall':'UTF-8'}" class="{if $child.children|count || $child.category_branch|count || $child.layouts|count || $child.type == 'rich_content'}adv-megamenu__submenu-trigger{/if}" {if $child.new_window}target="_blank" rel="noopener"{/if}>{$child.title|escape:'htmlall':'UTF-8'}</a>
</li>
{/foreach}
</ul>
{/if}
</div>
{assign var=hasSubmenuContent value=false}
{foreach from=$node.children item=child}
{if $child.children|count || $child.category_branch|count || $child.layouts|count || $child.type == 'rich_content'}
{assign var=hasSubmenuContent value=true}
{/if}
{/foreach}
{if $hasSubmenuContent || $node.layouts|count}
<div class="adv-megamenu__panel-content">
{if $hasSubmenuContent}
<div class="adv-megamenu__submenu-content">
{foreach from=$node.children item=child}
{if $child.children|count || $child.category_branch|count || $child.layouts|count || $child.type == 'rich_content'}
<section class="adv-megamenu__submenu-pane" data-submenu-pane="node-{$child.id}">
{if $child.type != 'rich_content'}
<h3 class="adv-megamenu__submenu-title">{$child.title|escape:'htmlall':'UTF-8'}</h3>
{/if}
{advRichContentIntro node=$child}
{if $child.category_branch|count}
{advPanelTree nodes=$child.category_branch}
{/if}
{if $child.children|count}
{advNodeCardsByType nodes=$child.children type='rich_content'}
{advNodeCardsByType nodes=$child.children type='category'}
{advNodeCardsByType nodes=$child.children type='cms'}
{advNodeCardsByType nodes=$child.children type='link'}
{/if}
{if $child.layouts|count}
{advLayoutCards layouts=$child.layouts}
{/if}
</section>
{/if}
{/foreach}
</div>
{/if}
{if $node.layouts|count}
{advLayoutCards layouts=$node.layouts}
{/if}
</div>
{/if}
</div>
</div>
{/if}
</li>
{/if}
{/foreach}
</ul>
</div>
<div class="adv-megamenu__mobile hidden-md-up">
<button type="button" class="adv-megamenu__toggle js-adv-menu-toggle" aria-expanded="false">
<span class="material-icons" aria-hidden="true">menu</span>
<span>{l s='Menu' d='Modules.Advancedmegamenu.Shop'}</span>
</button>
<div class="adv-megamenu__overlay js-adv-overlay">
<div class="adv-megamenu__drawer">
<div class="adv-megamenu__mobile-head">
<span>{l s='Menu' d='Modules.Advancedmegamenu.Shop'}</span>
<button type="button" class="adv-megamenu__close js-adv-menu-close" aria-label="{l s='Close menu' d='Modules.Advancedmegamenu.Shop'}">×</button>
</div>
<div class="adv-megamenu__mobile-viewport">
<section class="adv-megamenu__mobile-panel is-active" data-panel-id="root">
<ul class="adv-megamenu__mobile-list">
{foreach from=$menu.children item=node}
{if $node.active}
<li>
<div class="adv-megamenu__mobile-link-row">
<a href="{$node.url|escape:'htmlall':'UTF-8'}" {if $node.new_window}target="_blank" rel="noopener"{/if}>{$node.title|escape:'htmlall':'UTF-8'}</a>
{if $node.children|count || $node.category_branch|count || $node.layouts|count || $node.type == 'rich_content'}
<button type="button" class="js-adv-open-panel" data-target="adv-panel-{$node.id}"></button>
{/if}
</div>
</li>
{/if}
{/foreach}
</ul>
</section>
{advMobilePanels nodes=$menu.children}
</div>
</div>
</div>
</div>
</nav>