diff --git a/src/Core/Module/Contract/ResellerNavbarProviderInterface.php b/src/Core/Module/Contract/ResellerNavbarProviderInterface.php new file mode 100644 index 00000000..8bdf7dbd --- /dev/null +++ b/src/Core/Module/Contract/ResellerNavbarProviderInterface.php @@ -0,0 +1,25 @@ + + * @copyright 2025-2026 Vateron Media + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +interface ResellerNavbarProviderInterface { + /** + * Register reseller navbar items via the provided ResellerNavbarRegistry. + * + * Called in bootAll() after all modules have been booted. + * Use $registry->add() instead of ResellerNavbarRegistry::add() (static). + * + * Named distinctly from NavbarProviderInterface::registerNavbar() so a + * module can implement both interfaces (PHP methods aren't overloaded by + * parameter type). + */ + public function registerResellerNavbar(ResellerNavbarRegistry $registry): void; +} diff --git a/src/Core/Module/CoreResellerNavbarProvider.php b/src/Core/Module/CoreResellerNavbarProvider.php new file mode 100644 index 00000000..dbef796d --- /dev/null +++ b/src/Core/Module/CoreResellerNavbarProvider.php @@ -0,0 +1,184 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +class CoreResellerNavbarProvider implements ResellerNavbarProviderInterface { + /** + * ResellerNavbarProviderInterface implementation — delegates to register(). + */ + public function registerResellerNavbar(ResellerNavbarRegistry $registry): void { + self::register(); + } + + /** + * Register all core reseller navigation items with the ResellerNavbarRegistry. + */ + public static function register(): void { + self::_dashboard(); + self::_clients(); + self::_content(); + self::_categoryTemplates(); + self::_ticketsAndLogs(); + } + + // ── Dashboard ───────────────────────────────────────────────── + + private static function _dashboard(): void { + ResellerNavbarRegistry::add((new NavbarItem('dashboard')) + ->url('dashboard')->label('dashboard') + ->icon('ti tabler-smart-home')->order(100)); + } + + // ── Clients (Lines / MAG / Enigma / Active Codes / Sub-Resellers) ── + + /** + * Register the "Clients" section: Lines, MAG devices, Enigma devices, + * Active Codes and Sub-Resellers, each gated on the create_* permission + * that used to guard the whole hardcoded group. + */ + private static function _clients(): void { + // Lines + ResellerNavbarRegistry::add((new NavbarItem('user_lines')) + ->url('#')->label('user_lines') + ->icon('ti tabler-device-desktop')->permissions(['create_line'])->order(200)); + ResellerNavbarRegistry::add((new NavbarItem('user_lines.add')) + ->parent('user_lines')->url('line') + ->label('add_line')->order(10)); + ResellerNavbarRegistry::add((new NavbarItem('user_lines.trial')) + ->parent('user_lines')->url('line?trial=1') + ->label('generate_trial')->permissions(['can_generate_trials'])->order(20)); + ResellerNavbarRegistry::add((new NavbarItem('user_lines.manage')) + ->parent('user_lines')->url('lines') + ->label('manage_lines')->order(30)); + + // MAG devices + ResellerNavbarRegistry::add((new NavbarItem('mag_devices')) + ->url('#')->label('mag_devices') + ->icon('ti tabler-device-tv')->permissions(['create_mag'])->order(210)); + ResellerNavbarRegistry::add((new NavbarItem('mag_devices.add')) + ->parent('mag_devices')->url('mag') + ->label('add_mag')->order(10)); + ResellerNavbarRegistry::add((new NavbarItem('mag_devices.trial')) + ->parent('mag_devices')->url('mag?trial=1') + ->label('generate_trial')->permissions(['can_generate_trials'])->order(20)); + ResellerNavbarRegistry::add((new NavbarItem('mag_devices.manage')) + ->parent('mag_devices')->url('mags') + ->label('manage_mag_devices')->order(30)); + + // Enigma devices + ResellerNavbarRegistry::add((new NavbarItem('enigma_devices')) + ->url('#')->label('enigma_devices') + ->icon('ti tabler-cpu')->permissions(['create_enigma'])->order(220)); + ResellerNavbarRegistry::add((new NavbarItem('enigma_devices.add')) + ->parent('enigma_devices')->url('enigma') + ->label('add_enigma')->order(10)); + ResellerNavbarRegistry::add((new NavbarItem('enigma_devices.trial')) + ->parent('enigma_devices')->url('enigma?trial=1') + ->label('generate_trial')->permissions(['can_generate_trials'])->order(20)); + ResellerNavbarRegistry::add((new NavbarItem('enigma_devices.manage')) + ->parent('enigma_devices')->url('enigmas') + ->label('manage_enigma_devices')->order(30)); + + // Active codes (reuses create_line, matching the current gate) + ResellerNavbarRegistry::add((new NavbarItem('active_codes')) + ->url('#')->label('active_codes') + ->icon('ti tabler-key')->permissions(['create_line'])->order(230)); + ResellerNavbarRegistry::add((new NavbarItem('active_codes.add')) + ->parent('active_codes')->url('active_code') + ->label('generate_codes')->order(10)); + ResellerNavbarRegistry::add((new NavbarItem('active_codes.manage')) + ->parent('active_codes')->url('active_codes') + ->label('manage_codes')->order(20)); + ResellerNavbarRegistry::add((new NavbarItem('active_codes.batch')) + ->parent('active_codes')->url('active_codes_batch') + ->label('batch_manager')->order(30)); + + // Sub-resellers + ResellerNavbarRegistry::add((new NavbarItem('sub_resellers')) + ->url('#')->label('sub_resellers') + ->icon('ti tabler-users')->permissions(['create_sub_resellers'])->order(240)); + ResellerNavbarRegistry::add((new NavbarItem('sub_resellers.add')) + ->parent('sub_resellers')->url('user') + ->label('add_user')->order(10)); + ResellerNavbarRegistry::add((new NavbarItem('sub_resellers.manage')) + ->parent('sub_resellers')->url('users') + ->label('manage_users')->order(20)); + } + + // ── Content (flat top-level entries, grouped by view-level section) ── + + /** + * Register Content navigation items as independent top-level entries + * (no shared parent — the "content" caption is a view-level grouping, + * matching how admin registers standalone items like management.tickets). + * Each item carries can_view_vod directly, reproducing the current + * section-level gate without a separate section-visibility mechanism. + */ + private static function _content(): void { + ResellerNavbarRegistry::add((new NavbarItem('streams')) + ->url('streams')->label('streams') + ->icon('ti tabler-player-play')->permissions(['can_view_vod'])->order(300)); + ResellerNavbarRegistry::add((new NavbarItem('created_channels')) + ->url('created_channels')->label('created_channels') + ->icon('ti tabler-playlist-add')->permissions(['can_view_vod'])->order(310)); + ResellerNavbarRegistry::add((new NavbarItem('movies')) + ->url('movies')->label('movies') + ->icon('ti tabler-movie')->permissions(['can_view_vod'])->order(320)); + ResellerNavbarRegistry::add((new NavbarItem('episodes')) + ->url('episodes')->label('episodes') + ->icon('ti tabler-device-tv-old')->permissions(['can_view_vod'])->order(330)); + ResellerNavbarRegistry::add((new NavbarItem('radios')) + ->url('radios')->label('radios') + ->icon('ti tabler-broadcast')->permissions(['can_view_vod'])->order(340)); + ResellerNavbarRegistry::add((new NavbarItem('tv_guide')) + ->url('epg_view')->label('tv_guide') + ->icon('ti tabler-calendar-time')->permissions(['can_view_vod']) + ->desktopOnly()->order(350)); + } + + // ── Category Templates ───────────────────────────────────────── + + private static function _categoryTemplates(): void { + ResellerNavbarRegistry::add((new NavbarItem('category_templates')) + ->url('category_templates')->label('category_templates') + ->icon('ti tabler-layout-grid')->order(400)); + } + + // ── Tickets & Logs ──────────────────────────────────────────── + + private static function _ticketsAndLogs(): void { + ResellerNavbarRegistry::add((new NavbarItem('tickets')) + ->url('tickets')->label('tickets') + ->icon('ti tabler-ticket')->order(500)); + + ResellerNavbarRegistry::add((new NavbarItem('logs')) + ->url('#')->label('logs') + ->icon('ti tabler-clipboard-list')->order(510)); + ResellerNavbarRegistry::add((new NavbarItem('logs.live_connections')) + ->parent('logs')->url('live_connections') + ->label('live_connections')->permissions(['reseller_client_connection_logs'])->order(10)); + ResellerNavbarRegistry::add((new NavbarItem('logs.activity_logs')) + ->parent('logs')->url('line_activity') + ->label('activity_logs')->permissions(['reseller_client_connection_logs'])->order(20)); + ResellerNavbarRegistry::add((new NavbarItem('logs.user_logs')) + ->parent('logs')->url('user_logs') + ->label('user_logs')->order(30)); + } +} diff --git a/src/Core/Module/ModuleLoader.php b/src/Core/Module/ModuleLoader.php index e49fcfc6..e29937bd 100644 --- a/src/Core/Module/ModuleLoader.php +++ b/src/Core/Module/ModuleLoader.php @@ -20,6 +20,7 @@ use XcVm\Core\Module\Contract\CronProviderInterface; use XcVm\Core\Module\Contract\NavbarProviderInterface; use XcVm\Core\Module\Contract\PermissionProviderInterface; use XcVm\Core\Module\Contract\QuickToolsProviderInterface; +use XcVm\Core\Module\Contract\ResellerNavbarProviderInterface; use XcVm\Core\Module\Contract\RouteProviderInterface; use XcVm\Core\Module\Contract\ServiceProviderInterface; use XcVm\Core\Module\Contract\StreamMiddlewareProviderInterface; @@ -231,6 +232,15 @@ class ModuleLoader { $navbarRegistry = new NavbarRegistry(); (new CoreNavbarProvider())->registerNavbar($navbarRegistry); + // Module reseller sidebar contributions (ResellerNavbarProviderInterface). + // Separate storage from $navbarRegistry above so admin/reseller top-level + // keys (both use names like 'dashboard', 'lines') never collide. Reset + // before core registers so a re-boot in the same process (tests/CLI) + // does not accumulate stale entries. + $resellerNavbarRegistry = new ResellerNavbarRegistry(); + ResellerNavbarRegistry::reset(); + (new CoreResellerNavbarProvider())->registerResellerNavbar($resellerNavbarRegistry); + // Module topbar contributions are merged on top of Topbar's core literal // (see XcVm\Core\Util\Topbar::config). Reset so a re-boot in the same // process (tests/CLI) does not accumulate stale entries. @@ -268,6 +278,10 @@ class ModuleLoader { $module->registerNavbar($navbarRegistry); } + if ($module instanceof ResellerNavbarProviderInterface) { + $module->registerResellerNavbar($resellerNavbarRegistry); + } + if ($module instanceof TopbarProviderInterface) { $module->registerTopbar($topbarRegistry); } diff --git a/src/Core/Module/ResellerNavbarRegistry.php b/src/Core/Module/ResellerNavbarRegistry.php new file mode 100644 index 00000000..dd531d89 --- /dev/null +++ b/src/Core/Module/ResellerNavbarRegistry.php @@ -0,0 +1,139 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ + +class ResellerNavbarRegistry { + /** @var NavbarItem[] Key → NavbarItem map */ + private static $items = []; + + /** + * Register a single navigation item in the registry. + * + * Items are stored by their unique dot-separated key. + * Duplicate keys will overwrite previously registered items. + * + * @param NavbarItem $item The navigation item to register + */ + public static function add(NavbarItem $item): void { + self::$items[$item->key] = $item; + } + + /** + * Returns all top-level navigation items. + * + * Top-level items are those with parent === null. + * Items are sorted by their order property (ascending). + * + * @return NavbarItem[] Array of top-level items sorted by order + */ + public static function getTopLevel(): array { + return self::_sorted(null); + } + + /** + * Returns direct children of the given parent key. + * + * Only immediate children are returned (not descendants). + * Items are sorted by their order property (ascending). + * + * @param string $parentKey The dot-separated key of the parent item + * @return NavbarItem[] Array of child items sorted by order + */ + public static function getChildren(string $parentKey): array { + return self::_sorted($parentKey); + } + + /** + * Check if a navigation item has any child items. + * + * @param string $key The dot-separated key of the item to check + * @return bool True if the item has at least one child, false otherwise + */ + public static function hasChildren(string $key): bool { + foreach (self::$items as $item) { + if ($item->parent === $key) { + return true; + } + } + return false; + } + + /** + * Collapse dividers in an already visibility-filtered item list. + * + * Removes leading and trailing dividers and squashes consecutive ones + * into a single divider. + * + * @param NavbarItem[] $items Visible items in render order + * @return NavbarItem[] Items with orphan dividers removed + */ + public static function collapseDividers(array $items): array { + $result = []; + $prevDivider = true; // treat the start as a divider so a leading one drops + foreach ($items as $item) { + if ($item->divider) { + if ($prevDivider) { + continue; + } + $prevDivider = true; + } else { + $prevDivider = false; + } + $result[] = $item; + } + while ($result !== [] && end($result)->divider) { + array_pop($result); + } + return $result; + } + + /** + * Reset the registry by clearing all registered items. + * + * Primarily useful for unit testing to ensure a clean state + * between test cases. + */ + public static function reset(): void { + self::$items = []; + } + + /** + * Get all navigation items belonging to a specific parent. + * + * Internal helper method that filters and sorts items by their + * order property. Returns empty array if no items match. + * + * @param string|null $parent Parent key (null for top-level items) + * @return NavbarItem[] Array of matching items sorted by order + */ + private static function _sorted(?string $parent): array { + $result = []; + foreach (self::$items as $item) { + if ($item->parent === $parent) { + $result[] = $item; + } + } + usort($result, function ($a, $b) { + return $a->order - $b->order; + }); + return $result; + } +} diff --git a/src/Public/Views/layouts/reseller/header.php b/src/Public/Views/layouts/reseller/header.php index 3b89cf07..c243c2c2 100644 --- a/src/Public/Views/layouts/reseller/header.php +++ b/src/Public/Views/layouts/reseller/header.php @@ -4,17 +4,19 @@ * Bootstrap 5 reseller header — Vertical Menu Template. * * Mirrors admin/header.php but for the reseller surface: the reseller has - * NO server / admin-only chrome. The sidebar is built from a hardcoded reseller - * menu array (the legacy reseller navigation is hardcoded too, so there is no - * NavbarRegistry tree to walk here), gated by the same $rPermissions the legacy - * reseller header uses. The navbar carries the live header stats, the owner - * credits pill, a tickets link and the profile dropdown. + * NO server / admin-only chrome. The sidebar is built from ResellerNavbarRegistry + * (see menu.php), gated by the same $rPermissions the legacy reseller header + * uses. The navbar carries the live header stats, the owner credits pill, a + * tickets link and the profile dropdown. * * Rendered by renderUnifiedLayoutHeader('reseller') for every reseller page * (all migrated to the Bootstrap 5 shell). */ +use XcVm\Core\Auth\Authorization; use XcVm\Core\Enum\Theme; +use XcVm\Core\Module\NavbarItem; +use XcVm\Core\Module\ResellerNavbarRegistry; use XcVm\Core\Util\AdminHelpers; use XcVm\Domain\Line\LineService; @@ -26,6 +28,9 @@ if (count(get_included_files()) == 1) { // the "Generate Trial" menu entries and downstream views can read it. $rGenTrials = LineService::canGenerateTrials($rUserInfo['id']); $GLOBALS['rGenTrials'] = $rGenTrials; +// Folded into $rPermissions so the "Generate Trial" NavbarItems can use the +// ordinary ->permissions(['can_generate_trials']) gate like any other item. +$rPermissions['can_generate_trials'] = $rGenTrials; $xmIsDark = Theme::fromId($rUserInfo['theme'] ?? 0)->isDark(); @@ -36,239 +41,38 @@ $xmUiPrefs = json_decode($rUserInfo['ui_prefs'] ?? '', true) ?: []; $xmThemePref = $xmUiPrefs['theme'] ?? null; $xmBsTheme = $xmThemePref === 'dark' ? 'dark' : ($xmThemePref === 'light' ? 'light' : ($xmIsDark ? 'dark' : 'light')); -$xmPage = AdminHelpers::getPageName(); -$xmPermissions = $rPermissions ?? []; - /** - * Reseller sidebar, grouped into captioned sections (hardcoded, permission-gated). - * - * A section is ['title' => i18n key or '', 'show' => bool, 'items' => node[]] and - * renders its caption only when at least one item survives the permission gates. - * Each node is ['label' => i18n key, 'icon' => tabler class, 'url' => string, - * 'show' => bool, 'children' => node[]]; only depth-0 nodes carry an icon - * (matches the admin vertical-menu markup). + * Shared reseller navbar helpers (same contract as admin/header.php's + * _xc_nav_visible()/_xc_nav_label(), against ResellerNavbarRegistry and + * Authorization::hasResellerPermissions() instead of the admin equivalents). + * Guarded so a single request only ever defines them once. */ -$xmMenuSections = [ - [ - 'title' => '', - 'items' => [ - [ - 'label' => 'dashboard', - 'icon' => 'ti tabler-smart-home', - 'url' => 'dashboard', - 'show' => true, - ], - ], - ], - [ - 'title' => 'clients', - 'show' => !empty($xmPermissions['create_line']) || !empty($xmPermissions['create_mag']) || !empty($xmPermissions['create_enigma']) || !empty($xmPermissions['create_sub_resellers']), - 'items' => [ - [ - 'label' => 'user_lines', - 'icon' => 'ti tabler-device-desktop', - 'url' => '#', - 'show' => !empty($xmPermissions['create_line']), - 'children' => [ - ['label' => 'add_line', 'url' => 'line', 'show' => true], - ['label' => 'generate_trial', 'url' => 'line?trial=1', 'show' => $rGenTrials], - ['label' => 'manage_lines', 'url' => 'lines', 'show' => true], - ], - ], - [ - 'label' => 'mag_devices', - 'icon' => 'ti tabler-device-tv', - 'url' => '#', - 'show' => !empty($xmPermissions['create_mag']), - 'children' => [ - ['label' => 'add_mag', 'url' => 'mag', 'show' => true], - ['label' => 'generate_trial', 'url' => 'mag?trial=1', 'show' => $rGenTrials], - ['label' => 'manage_mag_devices', 'url' => 'mags', 'show' => true], - ], - ], - [ - 'label' => 'enigma_devices', - 'icon' => 'ti tabler-cpu', - 'url' => '#', - 'show' => !empty($xmPermissions['create_enigma']), - 'children' => [ - ['label' => 'add_enigma', 'url' => 'enigma', 'show' => true], - ['label' => 'generate_trial', 'url' => 'enigma?trial=1', 'show' => $rGenTrials], - ['label' => 'manage_enigma_devices', 'url' => 'enigmas', 'show' => true], - ], - ], - [ - 'label' => 'active_codes', - 'icon' => 'ti tabler-key', - 'url' => '#', - 'show' => !empty($xmPermissions['create_line']), - 'children' => [ - ['label' => 'generate_codes', 'url' => 'active_code', 'show' => true], - ['label' => 'manage_codes', 'url' => 'active_codes', 'show' => true], - ['label' => 'batch_manager', 'url' => 'active_codes_batch', 'show' => true], - ], - ], - [ - 'label' => 'sub_resellers', - 'icon' => 'ti tabler-users', - 'url' => '#', - 'show' => !empty($xmPermissions['create_sub_resellers']), - 'children' => [ - ['label' => 'add_user', 'url' => 'user', 'show' => true], - ['label' => 'manage_users', 'url' => 'users', 'show' => true], - ], - ], - ], - ], - [ - 'title' => 'content', - 'show' => !empty($xmPermissions['can_view_vod']), - 'items' => [ - ['label' => 'streams', 'icon' => 'ti tabler-player-play', 'url' => 'streams', 'show' => true], - ['label' => 'created_channels', 'icon' => 'ti tabler-playlist-add', 'url' => 'created_channels', 'show' => true], - ['label' => 'movies', 'icon' => 'ti tabler-movie', 'url' => 'movies', 'show' => true], - ['label' => 'episodes', 'icon' => 'ti tabler-device-tv-old', 'url' => 'episodes', 'show' => true], - ['label' => 'radios', 'icon' => 'ti tabler-broadcast', 'url' => 'radios', 'show' => true], - ['label' => 'tv_guide', 'icon' => 'ti tabler-calendar-time', 'url' => 'epg_view', 'show' => !$rMobile], - ], - ], - [ - 'title' => 'category_templates', - 'items' => [ - [ - 'label' => 'category_templates', - 'icon' => 'ti tabler-layout-grid', - 'url' => 'category_templates', - 'show' => true, - ], - ], - ], - [ - 'title' => 'tickets_and_logs', - 'items' => [ - [ - 'label' => 'tickets', - 'icon' => 'ti tabler-ticket', - 'url' => 'tickets', - 'show' => true, - ], - [ - 'label' => 'logs', - 'icon' => 'ti tabler-clipboard-list', - 'url' => '#', - 'show' => true, - 'children' => [ - ['label' => 'live_connections', 'url' => 'live_connections', 'show' => !empty($xmPermissions['reseller_client_connection_logs'])], - ['label' => 'activity_logs', 'url' => 'line_activity', 'show' => !empty($xmPermissions['reseller_client_connection_logs'])], - ['label' => 'user_logs', 'url' => 'user_logs', 'show' => true], - ], - ], - ], - ], -]; - -/** - * Render the reseller sidebar tree. - * - * Returns [html, isActive] so the active leaf and its ancestors are resolved in - * one traversal (ancestors gain `open active`). Only depth-0 nodes carry an icon. - */ -/** - * Whether a menu node points at the page being rendered. - * - * Two cases the plain basename match gets wrong: - * - `line` and `line?trial=1` share a basename, so both lit up at once. The - * trial entry matches only while ?trial=1 is set, and the plain one only - * while it is not. - * - Detail pages live under their own route (`ticket_view` under Tickets, - * `category_template` under Category Templates), which left the sidebar with - * nothing highlighted; the alias map folds them back onto their section. - */ -if (!function_exists('_xc_reseller_node_is_current')) { - function _xc_reseller_node_is_current(string $urlBase, string $url, string $page): bool { - if ($urlBase === '') { +if (!function_exists('_xc_reseller_nav_visible')) { + function _xc_reseller_nav_visible(NavbarItem $item, bool $mobile, array $settings): bool { + if ($item->desktopOnly && $mobile) return false; + if ($item->settingDisabled !== '' && !empty($settings[$item->settingDisabled])) return false; + if ($item->divider) return true; + if (!empty($item->permissions)) { + foreach ($item->permissions as $_p) { + if (Authorization::hasResellerPermissions($_p)) return true; + } return false; } - - $isTrialPage = (string) ($_GET['trial'] ?? '') === '1'; - if (str_contains($url, 'trial=1')) { - return $urlBase === $page && $isTrialPage; + if ($item->url === '#') { + foreach (ResellerNavbarRegistry::getChildren($item->key) as $_child) { + if (_xc_reseller_nav_visible($_child, $mobile, $settings)) return true; + } + return false; } - if (in_array($urlBase, ['line', 'mag', 'enigma'], true)) { - return $urlBase === $page && !$isTrialPage; - } - - $aliases = [ - 'category_templates' => ['category_template'], - 'tickets' => ['ticket', 'ticket_view'], - ]; - return $urlBase === $page || in_array($page, $aliases[$urlBase] ?? [], true); + return true; } } -/** - * Render a sidebar section caption (Vuexy `menu-header`). - */ -if (!function_exists('_xc_reseller_section_header')) { - function _xc_reseller_section_header(string $title, string $language): string { - if ($title === '') { - return ''; - } - return ''; - } -} - -if (!function_exists('_xc_reseller_menu_node')) { - function _xc_reseller_menu_node(array $item, int $depth, string $page, string $language): array { - if (empty($item['show'])) { - return ['', false]; - } - $children = array_filter($item['children'] ?? [], static fn($c) => !empty($c['show'])); - - $childHtml = ''; - $childActive = false; - if ($children) { - $sub = ''; - foreach ($children as $child) { - [$node, $active] = _xc_reseller_menu_node($child, $depth + 1, $page, $language); - $sub .= $node; - $childActive = $childActive || $active; - } - if ($sub !== '') { - $childHtml = ''; - } - } - $hasKids = $childHtml !== ''; - - $url = (string) ($item['url'] ?? '#'); - - // A parent whose children were all hidden by permissions has nothing left - // to expand, and its own '#' url goes nowhere — drop it instead of leaving - // a dead toggle in the sidebar. - if (!$hasKids && isset($item['children']) && ($url === '#' || $url === '')) { - return ['', false]; - } - - // Match the current page by the item's URL basename (strip any query). - $urlBase = $url !== '#' && $url !== '' ? basename(explode('?', $url)[0]) : ''; - $selfActive = _xc_reseller_node_is_current($urlBase, $url, $page); - $active = $selfActive || ($hasKids && $childActive); - - $liClass = 'menu-item' . ($active ? ' active' : '') . ($hasKids && $active ? ' open' : ''); - $href = htmlspecialchars($hasKids ? 'javascript:void(0);' : $url, ENT_QUOTES); - $icon = $depth === 0 - ? '' - : ''; - $label = htmlspecialchars($language::get($item['label']), ENT_QUOTES); - - $html = '
  • '; - $html .= ''; - $html .= $icon . '
    ' . $label . '
    '; - $html .= '
    ' . $childHtml . '
  • '; - - return [$html, $active]; +if (!function_exists('_xc_reseller_nav_label')) { + function _xc_reseller_nav_label(NavbarItem $item, string $language): string { + return $item->translationKey + ? $language::get($item->translationKey) + : htmlspecialchars($item->fallbackTitle, ENT_QUOTES); } } $xmCurrentLang = \XcVm\Core\Localization\Translator::current(); @@ -343,43 +147,7 @@ $xmUiPrefs['rtl'] = $xmIsRtl;
    - - - +
    diff --git a/src/Public/Views/layouts/reseller/menu.php b/src/Public/Views/layouts/reseller/menu.php new file mode 100644 index 00000000..b211533d --- /dev/null +++ b/src/Public/Views/layouts/reseller/menu.php @@ -0,0 +1,239 @@ + 'category_templates', + 'ticket' => 'tickets', + 'ticket_view' => 'tickets', + ]; + + public function __construct( + private bool $mobile, + private array $settings, + private string $language, + private string $page, + ) { + } + + /** + * Full contents of