diff --git a/.gitignore b/.gitignore index fc9d017..8237a80 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ /translations/*.php /vendor/ /.php_cs.cache +/views/img/uploads/ +/views/img/generated/ +/views/css/generated/ diff --git a/README.md b/README.md index 4d8e136..135c1f7 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/advancedmegamenu.php b/advancedmegamenu.php new file mode 100644 index 0000000..cb1da6e --- /dev/null +++ b/advancedmegamenu.php @@ -0,0 +1,918 @@ + */ + 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> $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 $node + * @param array $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 $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 $node + * + * @return array + */ + 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 $node + * + * @return array> + */ + 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 $productIds + * + * @return array> + */ + 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> + */ + 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> $categories + * + * @return array> + */ + 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 $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 $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 $node + * + * @return array + */ + 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, '/'); + } +} diff --git a/composer.json b/composer.json index e860b29..474b797 100644 --- a/composer.json +++ b/composer.json @@ -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" } diff --git a/config.xml b/config.xml index 464f49b..3fa49c1 100644 --- a/config.xml +++ b/config.xml @@ -1,10 +1,10 @@ - ps_mainmenu - - - - + advancedmegamenu + + + + 1 1 diff --git a/src/Classes/SpriteGenerator.php b/src/Classes/SpriteGenerator.php new file mode 100644 index 0000000..1b9cbcb --- /dev/null +++ b/src/Classes/SpriteGenerator.php @@ -0,0 +1,139 @@ +moduleDir = rtrim($moduleDir, '/'); + $this->moduleUri = rtrim($moduleUri, '/') . '/'; + } + + /** + * @param array $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); + } +} diff --git a/src/Model/AdvMenuNode.php b/src/Model/AdvMenuNode.php new file mode 100644 index 0000000..361690e --- /dev/null +++ b/src/Model/AdvMenuNode.php @@ -0,0 +1,32 @@ + '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'], + ], + ]; +} diff --git a/src/Repository/MenuRepository.php b/src/Repository/MenuRepository.php new file mode 100644 index 0000000..e00e4b7 --- /dev/null +++ b/src/Repository/MenuRepository.php @@ -0,0 +1,270 @@ +db = $db; + $this->module = $module; + } + + /** + * @param array $languages + * + * @return array> + */ + 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> + */ + 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> + */ + 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 $itemIds + * + * @return array> + */ + 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 $layoutIds + * + * @return array> + */ + 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 $itemIds + * @param array $languages + * + * @return array>> + */ + 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 $node + * + * @return array + */ + 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 $node + * + * @return array + */ + 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, '/'); + } +} diff --git a/src/autoload.php b/src/autoload.php new file mode 100644 index 0000000..f76c659 --- /dev/null +++ b/src/autoload.php @@ -0,0 +1,17 @@ + .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; +} diff --git a/views/js/admin.js b/views/js/admin.js new file mode 100644 index 0000000..23ccf09 --- /dev/null +++ b/views/js/admin.js @@ -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 = '' + + '
' + + ' ' + escapeHtml(title || 'Menu item') + '' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + 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 = '' + + '

' + escapeHtml(language.iso_code + ' / ' + language.name) + '

' + + '' + + '' + + ''; + + 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 = '' + + '
' + + ' Rich block' + + '
' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + '
' + + '
' + + '
'; + + 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 = '#' + escapeHtml(String(productId)) + ''; + 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, '''); + } + + 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(); + } +})(); diff --git a/views/js/megamenu.js b/views/js/megamenu.js new file mode 100644 index 0000000..ecc99f4 --- /dev/null +++ b/views/js/megamenu.js @@ -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); + }); +})(); diff --git a/views/templates/admin/configure.tpl b/views/templates/admin/configure.tpl new file mode 100644 index 0000000..dc0276f --- /dev/null +++ b/views/templates/admin/configure.tpl @@ -0,0 +1,42 @@ +
+ + +
+ + +
+
+ + {l s='Mega menu builder' d='Modules.Advancedmegamenu.Admin'} +
+ +

+ {l s='Build the navigation tree, attach promo blocks and products, then save to regenerate the sprite and front cache.' d='Modules.Advancedmegamenu.Admin'} +

+ +
+ +
+ +
+
+ +
+ +
+
+
diff --git a/views/templates/hook/megamenu.tpl b/views/templates/hook/megamenu.tpl new file mode 100644 index 0000000..978ec3c --- /dev/null +++ b/views/templates/hook/megamenu.tpl @@ -0,0 +1,286 @@ +{function name=advCategoryTree nodes=[]} + {if $nodes|count} + + {/if} +{/function} + +{function name=advPanelTree nodes=[]} + {if $nodes|count} + + {/if} +{/function} + +{function name=advNodeCards nodes=[]} + {if $nodes|count} +
+ {foreach from=$nodes item=treeNode} +
+ +
+ {/foreach} +
+ {/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'} + + {/if} +{/function} + +{function name=advLayoutCards layouts=[]} + {if $layouts|count} +
+ {foreach from=$layouts item=layout} +
+
+ {if $layout.custom_image_url} +
+ {$layout.title|escape:'htmlall':'UTF-8'} +
+ {/if} +
+ {if $layout.show_title} +

{$layout.title|escape:'htmlall':'UTF-8'}

+ {/if} + {if $layout.description && $layout.block_type != 'products'} +

{$layout.description|escape:'htmlall':'UTF-8'}

+ {/if} + {if $layout.products|count} + + {/if} +
+
+
+ {/foreach} +
+ {/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'} +
+
+ + {$node.title|escape:'htmlall':'UTF-8'} +
+ +
+ {advMobilePanels nodes=$node.children} + {/if} + {/foreach} +{/function} + +