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
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:
@@ -2,3 +2,6 @@
|
||||
/translations/*.php
|
||||
/vendor/
|
||||
/.php_cs.cache
|
||||
/views/img/uploads/
|
||||
/views/img/generated/
|
||||
/views/css/generated/
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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, '/');
|
||||
}
|
||||
}
|
||||
+7
-13
@@ -1,28 +1,22 @@
|
||||
{
|
||||
"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"
|
||||
"prestashop/php-dev-tools": "^4.3"
|
||||
},
|
||||
"config": {
|
||||
"preferred-install": "dist",
|
||||
"prepend-autoloader": false
|
||||
},
|
||||
"type": "prestashop-module",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"ps_mainmenu.php"
|
||||
]
|
||||
}
|
||||
"type": "prestashop-module"
|
||||
}
|
||||
|
||||
+5
-5
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -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, '/');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
< {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>
|
||||
Reference in New Issue
Block a user