diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 5f85400..3febbe5 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { languageGuard } from './guards/language.guard'; import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard'; import { adminAuthGuard } from './core/admin-auth/admin-auth.guard'; import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard'; +import { AdminLayoutComponent } from './features/admin/shell/admin-layout.component'; import { environment } from '../environments/environment'; // Core routes (same across all brands) @@ -50,41 +51,82 @@ const coreRoutes: Routes = [ { path: 'backoffice', canActivate: [adminAuthGuard], + component: AdminLayoutComponent, children: [ { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: 'dashboard', - loadComponent: () => import('./features/admin/dashboard/pages/admin-dashboard-page.component').then(m => m.AdminDashboardPageComponent) + loadComponent: () => import('./features/admin/dashboard/pages/admin-dashboard-page.component').then(m => m.AdminDashboardPageComponent), + data: { + titleKey: 'adminShell.pages.dashboard.title', + descriptionKey: 'adminShell.pages.dashboard.description', + breadcrumb: [{ labelKey: 'adminShell.pages.dashboard.title' }] + } }, { path: 'products', - loadComponent: () => import('./features/admin/products/pages/admin-products-list-page.component').then(m => m.AdminProductsListPageComponent) + loadComponent: () => import('./features/admin/products/pages/admin-products-list-page.component').then(m => m.AdminProductsListPageComponent), + data: { + titleKey: 'adminShell.pages.products.title', + descriptionKey: 'adminShell.pages.products.description', + breadcrumb: [{ labelKey: 'adminShell.nav.products' }] + } }, { path: 'products/create', - loadComponent: () => import('./features/admin/products/pages/admin-product-editor-page.component').then(m => m.AdminProductEditorPageComponent) + loadComponent: () => import('./features/admin/products/pages/admin-product-editor-page.component').then(m => m.AdminProductEditorPageComponent), + data: { + titleKey: 'adminShell.pages.productCreate.title', + descriptionKey: 'adminShell.pages.productCreate.description', + breadcrumb: [{ labelKey: 'adminShell.nav.products', path: ['products'] }, { labelKey: 'adminShell.pages.productCreate.title' }] + } }, { path: 'products/:id/edit', - loadComponent: () => import('./features/admin/products/pages/admin-product-editor-page.component').then(m => m.AdminProductEditorPageComponent) + loadComponent: () => import('./features/admin/products/pages/admin-product-editor-page.component').then(m => m.AdminProductEditorPageComponent), + data: { + titleKey: 'adminShell.pages.productEdit.title', + descriptionKey: 'adminShell.pages.productEdit.description', + breadcrumb: [{ labelKey: 'adminShell.nav.products', path: ['products'] }, { labelKey: 'adminShell.pages.productEdit.title' }] + } }, { path: 'products/:id/duplicate', - loadComponent: () => import('./features/admin/products/pages/admin-product-editor-page.component').then(m => m.AdminProductEditorPageComponent) + loadComponent: () => import('./features/admin/products/pages/admin-product-editor-page.component').then(m => m.AdminProductEditorPageComponent), + data: { + titleKey: 'adminShell.pages.productDuplicate.title', + descriptionKey: 'adminShell.pages.productDuplicate.description', + breadcrumb: [{ labelKey: 'adminShell.nav.products', path: ['products'] }, { labelKey: 'adminShell.pages.productDuplicate.title' }] + } }, { path: 'categories', - loadComponent: () => import('./features/admin/categories/pages/admin-categories-list-page.component').then(m => m.AdminCategoriesListPageComponent) + loadComponent: () => import('./features/admin/categories/pages/admin-categories-list-page.component').then(m => m.AdminCategoriesListPageComponent), + data: { + titleKey: 'adminShell.pages.categories.title', + descriptionKey: 'adminShell.pages.categories.description', + breadcrumb: [{ labelKey: 'adminShell.nav.categories' }] + } }, { path: 'categories/create', loadComponent: () => import('./features/admin/categories/pages/admin-category-editor-page.component').then(m => m.AdminCategoryEditorPageComponent), - canDeactivate: [adminCategoryDirtyGuard] + canDeactivate: [adminCategoryDirtyGuard], + data: { + titleKey: 'adminShell.pages.categoryCreate.title', + descriptionKey: 'adminShell.pages.categoryCreate.description', + breadcrumb: [{ labelKey: 'adminShell.nav.categories', path: ['categories'] }, { labelKey: 'adminShell.pages.categoryCreate.title' }] + } }, { path: 'categories/:id/edit', loadComponent: () => import('./features/admin/categories/pages/admin-category-editor-page.component').then(m => m.AdminCategoryEditorPageComponent), - canDeactivate: [adminCategoryDirtyGuard] + canDeactivate: [adminCategoryDirtyGuard], + data: { + titleKey: 'adminShell.pages.categoryEdit.title', + descriptionKey: 'adminShell.pages.categoryEdit.description', + breadcrumb: [{ labelKey: 'adminShell.nav.categories', path: ['categories'] }, { labelKey: 'adminShell.pages.categoryEdit.title' }] + } }, { // Static Pages is a first-class Project Editor module (Sprint X+2), not a @@ -96,32 +138,66 @@ const coreRoutes: Routes = [ }, { path: 'transactions', - loadComponent: () => import('./features/admin/transactions/pages/admin-transactions-list-page.component').then(m => m.AdminTransactionsListPageComponent) + loadComponent: () => import('./features/admin/transactions/pages/admin-transactions-list-page.component').then(m => m.AdminTransactionsListPageComponent), + data: { + titleKey: 'adminShell.pages.transactions.title', + descriptionKey: 'adminShell.pages.transactions.description', + breadcrumb: [{ labelKey: 'adminShell.nav.transactions' }] + } }, { path: 'orders', - loadComponent: () => import('./features/admin/orders/pages/admin-orders-list-page.component').then(m => m.AdminOrdersListPageComponent) + loadComponent: () => import('./features/admin/orders/pages/admin-orders-list-page.component').then(m => m.AdminOrdersListPageComponent), + data: { + titleKey: 'adminShell.pages.orders.title', + descriptionKey: 'adminShell.pages.orders.description', + breadcrumb: [{ labelKey: 'adminShell.nav.orders' }] + } }, { path: 'orders/:id', - loadComponent: () => import('./features/admin/orders/pages/admin-order-detail-page.component').then(m => m.AdminOrderDetailPageComponent) + loadComponent: () => import('./features/admin/orders/pages/admin-order-detail-page.component').then(m => m.AdminOrderDetailPageComponent), + data: { + titleKey: 'adminShell.pages.orderDetail.title', + descriptionKey: 'adminShell.pages.orderDetail.description', + breadcrumb: [{ labelKey: 'adminShell.nav.orders', path: ['orders'] }, { labelKey: 'adminShell.pages.orderDetail.title' }] + } }, { path: 'media', loadComponent: () => import('./features/backoffice/media/media-library-page.component').then(m => m.MediaLibraryPageComponent), - data: { titleKey: 'dashboard.actionMediaLibrary' } + data: { + titleKey: 'adminShell.pages.media.title', + descriptionKey: 'adminShell.pages.media.description', + breadcrumb: [{ labelKey: 'adminShell.nav.mediaLibrary' }] + } }, { path: 'users', - loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent) + loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent), + data: { + titleKey: 'adminShell.pages.users.title', + descriptionKey: 'adminShell.pages.users.description', + breadcrumb: [{ labelKey: 'adminShell.nav.users' }] + } }, { path: 'monitoring', - loadComponent: () => import('./features/admin/monitoring/pages/admin-monitoring-page.component').then(m => m.AdminMonitoringPageComponent) + loadComponent: () => import('./features/admin/monitoring/pages/admin-monitoring-page.component').then(m => m.AdminMonitoringPageComponent), + data: { + titleKey: 'adminShell.pages.monitoring.title', + descriptionKey: 'adminShell.pages.monitoring.description', + breadcrumb: [{ labelKey: 'adminShell.nav.monitoring' }] + } }, { path: 'analytics', - loadComponent: () => import('./features/admin/analytics/pages/admin-analytics-page.component').then(m => m.AdminAnalyticsPageComponent) + loadComponent: () => import('./features/admin/analytics/pages/admin-analytics-page.component').then(m => m.AdminAnalyticsPageComponent), + data: { + titleKey: 'adminShell.pages.analytics.title', + descriptionKey: 'adminShell.pages.analytics.description', + breadcrumb: [{ labelKey: 'adminShell.nav.analytics' }] + } }, { path: '**', redirectTo: 'dashboard' } ] diff --git a/src/app/app.ts b/src/app/app.ts index 68c59e5..7bf6009 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -28,6 +28,7 @@ import { TelegramLoginComponent } from './components/telegram-login/telegram-log export class App implements OnInit { protected title = ''; isHomePage = signal(true); + isAdminRoute = signal(false); checkingServer = signal(true); serverAvailable = signal(false); @@ -62,6 +63,9 @@ export class App implements OnInit { const url = navEnd.urlAfterRedirects || navEnd.url; // Home pages: /ru, /en, /hy (with or without trailing slash) this.isHomePage.set(/^\/[a-z]{2}\/?$/.test(url) || url === '/' || url === ''); + // Admin backoffice owns its own shell (AdminLayoutComponent) - the + // storefront header/back-button/footer never render there. + this.isAdminRoute.set(/^\/[a-z]{2}\/backoffice(\/|$|\?)/.test(url)); }); } diff --git a/src/app/features/admin/shell/admin-layout.component.html b/src/app/features/admin/shell/admin-layout.component.html new file mode 100644 index 0000000..56a4323 --- /dev/null +++ b/src/app/features/admin/shell/admin-layout.component.html @@ -0,0 +1,160 @@ +{{ 'adminShell.skipToContent' | translate }} + +
+ @if (mobileDrawerOpen()) { +
+ } + + + +
+ + +
+ +

{{ pageTitleKey() | translate }}

+

{{ pageDescriptionKey() | translate }}

+
+ +
+ + + + + + +
+ + @if (notificationsOpen()) { + + } +
+ + +
+
+ +
+ +
+ + + +
diff --git a/src/app/features/admin/shell/admin-layout.component.scss b/src/app/features/admin/shell/admin-layout.component.scss new file mode 100644 index 0000000..d469d6f --- /dev/null +++ b/src/app/features/admin/shell/admin-layout.component.scss @@ -0,0 +1,500 @@ +:host { + --admin-sidebar-width: 280px; + --admin-sidebar-width-collapsed: 72px; + --admin-topbar-height: 72px; + display: block; + height: 100vh; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.admin-layout__skip-link { + position: absolute; + top: -100px; + left: var(--space-md); + z-index: 1000; + background: var(--bg-primary); + color: var(--text-primary); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-md); + + &:focus { + top: var(--space-sm); + } +} + +.admin-layout { + display: grid; + grid-template-columns: var(--admin-sidebar-width) 1fr; + grid-template-rows: var(--admin-topbar-height) 1fr; + grid-template-areas: + 'sidebar topbar' + 'sidebar content'; + height: 100vh; + background: var(--bg-secondary); + color: var(--text-primary); +} + +.admin-layout__backdrop { + position: fixed; + inset: 0; + /* Neutral modal/drawer scrim - intentionally not a themed token (Elevation + section covers floating surfaces, not the dimming layer behind them); + stays black-alpha regardless of tenant theme like the rest of the app's + overlay scrims. */ + background: rgba(0, 0, 0, 0.45); + z-index: 30; + display: none; +} + +/* ---------- Sidebar ---------- */ + +.admin-layout__sidebar { + grid-area: sidebar; + display: flex; + flex-direction: column; + height: 100vh; + position: sticky; + top: 0; + overflow-y: auto; + background: var(--bg-primary); + border-right: 1px solid var(--border-color); + z-index: 40; +} + +.admin-layout__brand { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + text-decoration: none; + color: var(--text-primary); + font-weight: 700; + border-bottom: 1px solid var(--border-color); +} + +.admin-layout__brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: var(--gradient-primary); + color: #fff; + flex-shrink: 0; +} + +.admin-layout__nav-list { + list-style: none; + margin: 0; + padding: var(--space-sm); + display: flex; + flex-direction: column; + gap: 2px; +} + +.admin-layout__nav-list--bottom { + margin-top: auto; + border-top: 1px solid var(--border-color); + padding-top: var(--space-sm); +} + +.admin-layout__nav-group { + padding: var(--space-md) var(--space-sm) var(--space-xs); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--text-light); +} + +.admin-layout__nav-link { + display: flex; + align-items: center; + gap: var(--space-sm); + width: 100%; + padding: var(--space-sm) var(--space-md); + border: none; + background: none; + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 1rem; + text-align: left; + text-decoration: none; + cursor: pointer; + + .pi { + font-size: 1rem; + width: 1.2rem; + flex-shrink: 0; + } + + &:hover { + background: color-mix(in srgb, var(--primary-color) 8%, transparent); + color: var(--primary-color); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + + &--active { + background: color-mix(in srgb, var(--primary-color) 8%, transparent); + color: var(--primary-color); + font-weight: 600; + } + + &--disabled { + color: var(--text-light); + cursor: not-allowed; + + &:hover { + background: none; + color: var(--text-light); + } + } +} + +.admin-layout__soon-badge { + margin-left: auto; + font-size: 0.7rem; + font-weight: 600; + padding: 2px 8px; + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--primary-color) 8%, transparent); + color: var(--primary-color); + border: 1px solid color-mix(in srgb, var(--primary-color) 20%, transparent); +} + +/* ---------- Topbar ---------- */ + +.admin-layout__topbar { + grid-area: topbar; + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: var(--space-md); + padding: 0 var(--space-lg); + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + min-width: 0; +} + +.admin-layout__menu-toggle { + display: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + flex-shrink: 0; + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } +} + +.admin-layout__heading { + min-width: 0; + flex: 1; + padding: var(--space-sm) 0; +} + +.admin-layout__breadcrumb { + ol { + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0 0 2px; + padding: 0; + font-size: 0.7rem; + color: var(--text-light); + } + + li { + display: flex; + align-items: center; + } + + a { + color: var(--text-light); + text-decoration: none; + + &:hover { + color: var(--primary-color); + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + } + + [aria-current='page'] { + color: var(--text-secondary); + } +} + +.admin-layout__breadcrumb-sep { + margin: 0 var(--space-xs); +} + +.admin-layout__title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-layout__description { + margin: 2px 0 0; + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-layout__topbar-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-shrink: 0; +} + +.admin-layout__search { + position: relative; + display: none; + + input { + width: 220px; + padding: 10px 12px 10px 32px; + border: 1px solid var(--border-color); + border-radius: var(--radius-field, var(--radius-sm)); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 1rem; + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + } +} + +.admin-layout__search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-light); + font-size: 1rem; +} + +.admin-layout__icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; + + &:hover:not(:disabled) { + background: var(--bg-tertiary); + color: var(--text-primary); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.admin-layout__notifications { + position: relative; +} + +.admin-layout__notifications-panel { + position: absolute; + right: 0; + top: calc(100% + 8px); + width: 240px; + padding: var(--space-md); + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + font-size: 1rem; + color: var(--text-secondary); + z-index: 25; + + p { + margin: 0; + } +} + +.admin-layout__account { + display: flex; + align-items: center; + gap: var(--space-xs); + padding-left: var(--space-sm); + border-left: 1px solid var(--border-color); +} + +.admin-layout__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--primary-color); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + flex-shrink: 0; +} + +.admin-layout__account-name { + font-size: 0.75rem; + font-weight: 600; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: none; +} + +/* ---------- Content / inspector ---------- */ + +.admin-layout__content { + grid-area: content; + overflow-y: auto; + padding: var(--space-lg); + + &:focus-visible { + outline: none; + } +} + +.admin-layout__inspector { + display: none; + width: 0; +} + +/* ---------- Responsive ---------- */ + +@media (min-width: 640px) { + .admin-layout__search { + display: block; + } + + .admin-layout__account-name { + display: block; + } +} + +/* Tablet: icon-only rail */ +@media (max-width: 1023px) and (min-width: 768px) { + /* Doubled selector: deliberate specificity bump so this reliably wins over + the base .admin-layout grid-template-columns declaration regardless of + the two rules' relative order after the build's CSS bundling pass. */ + .admin-layout.admin-layout { + grid-template-columns: var(--admin-sidebar-width-collapsed) 1fr; + } + + .admin-layout__brand-name, + .admin-layout__nav-label, + .admin-layout__soon-badge { + display: none; + } + + .admin-layout__brand { + justify-content: center; + } + + .admin-layout__nav-link { + justify-content: center; + } +} + +/* Mobile: drawer */ +@media (max-width: 767px) { + .admin-layout.admin-layout { + grid-template-columns: 0 1fr; + } + + .admin-layout__menu-toggle { + display: inline-flex; + } + + .admin-layout__sidebar { + position: fixed; + inset: 0 auto 0 0; + width: min(var(--admin-sidebar-width), 84vw); + transform: translateX(-100%); + transition: transform 0.2s ease; + box-shadow: var(--shadow-lg); + } + + .admin-layout--drawer-open { + .admin-layout__sidebar { + transform: translateX(0); + } + + .admin-layout__backdrop { + display: block; + } + } + + .admin-layout__title, + .admin-layout__description { + max-width: 55vw; + } +} + +@media (prefers-reduced-motion: reduce) { + .admin-layout__sidebar { + transition: none; + } +} + +@media (prefers-color-scheme: dark) { + .admin-layout__backdrop { + /* Same neutral-scrim exception as the light-mode rule above, just darker. */ + background: rgba(0, 0, 0, 0.6); + } +} diff --git a/src/app/features/admin/shell/admin-layout.component.ts b/src/app/features/admin/shell/admin-layout.component.ts new file mode 100644 index 0000000..b5b1cce --- /dev/null +++ b/src/app/features/admin/shell/admin-layout.component.ts @@ -0,0 +1,142 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, HostListener, computed, inject, signal, viewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { filter } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { TranslatePipe } from '../../../i18n/translate.pipe'; +import { TranslateService } from '../../../i18n/translate.service'; +import { LanguageService } from '../../../services/language.service'; +import { AdminAuthService } from '../../../core/admin-auth/admin-auth.service'; +import { ADMIN_NAV_BOTTOM, ADMIN_NAV_PRIMARY, AdminBreadcrumbEntry, AdminNavEntry } from './admin-nav.model'; + +@Component({ + selector: 'app-admin-layout', + standalone: true, + imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, TranslatePipe], + templateUrl: './admin-layout.component.html', + styleUrl: './admin-layout.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdminLayoutComponent { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + private readonly translate = inject(TranslateService); + private readonly languageService = inject(LanguageService); + private readonly adminAuth = inject(AdminAuthService); + + private readonly drawerEl = viewChild>('drawer'); + private readonly menuToggleEl = viewChild>('menuToggle'); + + readonly navPrimary: AdminNavEntry[] = ADMIN_NAV_PRIMARY; + readonly navBottom: AdminNavEntry[] = ADMIN_NAV_BOTTOM; + + readonly mobileDrawerOpen = signal(false); + readonly notificationsOpen = signal(false); + + readonly currentLang = this.languageService.currentLanguage; + readonly displayName = this.adminAuth.displayName; + + readonly initials = computed(() => { + const name = this.displayName(); + if (!name) { + return '?'; + } + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map(part => part[0]?.toUpperCase()) + .join(''); + }); + + private readonly breadcrumbSignal = signal([]); + private readonly titleKeySignal = signal('adminShell.pages.dashboard.title'); + private readonly descriptionKeySignal = signal('adminShell.pages.dashboard.description'); + + readonly breadcrumb = this.breadcrumbSignal.asReadonly(); + readonly pageTitleKey = this.titleKeySignal.asReadonly(); + readonly pageDescriptionKey = this.descriptionKeySignal.asReadonly(); + + constructor() { + this.readRouteData(); + this.router.events + .pipe( + filter(event => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.readRouteData(); + this.closeMobileDrawer(); + this.notificationsOpen.set(false); + }); + } + + @HostListener('document:keydown.escape') + onEscape(): void { + if (this.mobileDrawerOpen()) { + this.closeMobileDrawer(); + return; + } + if (this.notificationsOpen()) { + this.notificationsOpen.set(false); + } + } + + toggleMobileDrawer(): void { + this.mobileDrawerOpen.update(open => !open); + if (this.mobileDrawerOpen()) { + queueMicrotask(() => { + const drawer = this.drawerEl()?.nativeElement; + drawer?.querySelector('a, button')?.focus(); + }); + } + } + + closeMobileDrawer(): void { + if (!this.mobileDrawerOpen()) { + return; + } + this.mobileDrawerOpen.set(false); + this.menuToggleEl()?.nativeElement.focus(); + } + + toggleNotifications(): void { + this.notificationsOpen.update(open => !open); + } + + adminLinkFor(entry: Extract): string[] { + const lang = this.currentLang(); + if (entry.absolutePath) { + return ['/', lang, ...entry.absolutePath]; + } + return ['/', lang, 'backoffice', ...(entry.path ?? [])]; + } + + breadcrumbLink(entry: AdminBreadcrumbEntry): string[] { + return ['/', this.currentLang(), 'backoffice', ...(entry.path ?? [])]; + } + + logout(): void { + this.adminAuth.logout(); + } + + cardTitle(key: string): string { + return this.translate.t(key); + } + + private readRouteData(): void { + let leaf = this.route; + while (leaf.firstChild) { + leaf = leaf.firstChild; + } + const data = leaf.snapshot.data as { + titleKey?: string; + descriptionKey?: string; + breadcrumb?: AdminBreadcrumbEntry[]; + }; + this.titleKeySignal.set(data.titleKey ?? 'adminShell.pages.dashboard.title'); + this.descriptionKeySignal.set(data.descriptionKey ?? 'adminShell.pages.dashboard.description'); + this.breadcrumbSignal.set(data.breadcrumb ?? []); + } +} diff --git a/src/app/features/admin/shell/admin-nav.model.ts b/src/app/features/admin/shell/admin-nav.model.ts new file mode 100644 index 0000000..1b57c52 --- /dev/null +++ b/src/app/features/admin/shell/admin-nav.model.ts @@ -0,0 +1,62 @@ +export interface AdminNavLink { + type: 'link'; + id: string; + icon: string; + labelKey: string; + /** Segments appended to /:lang/backoffice/. Mutually exclusive with absolutePath. */ + path?: string[]; + /** Segments appended to /:lang/ directly (routes outside /backoffice, e.g. the project editor). */ + absolutePath?: string[]; + /** No route exists yet - rendered as a disabled item with a "coming soon" badge. */ + comingSoon?: boolean; +} + +export interface AdminNavGroup { + type: 'group'; + labelKey: string; +} + +export interface AdminNavAction { + type: 'action'; + id: string; + icon: string; + labelKey: string; +} + +export type AdminNavEntry = AdminNavLink | AdminNavGroup | AdminNavAction; + +export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [ + { type: 'link', id: 'dashboard', icon: 'pi-home', labelKey: 'adminShell.nav.dashboard', path: ['dashboard'] }, + { type: 'group', labelKey: 'adminShell.nav.catalogGroup' }, + { type: 'link', id: 'products', icon: 'pi-box', labelKey: 'adminShell.nav.products', path: ['products'] }, + { type: 'link', id: 'categories', icon: 'pi-tags', labelKey: 'adminShell.nav.categories', path: ['categories'] }, + { type: 'link', id: 'orders', icon: 'pi-shopping-cart', labelKey: 'adminShell.nav.orders', path: ['orders'] }, + { type: 'link', id: 'transactions', icon: 'pi-credit-card', labelKey: 'adminShell.nav.transactions', path: ['transactions'] }, + { type: 'link', id: 'reviews', icon: 'pi-star', labelKey: 'adminShell.nav.reviews', comingSoon: true }, + { type: 'link', id: 'reports', icon: 'pi-chart-bar', labelKey: 'adminShell.nav.reports', comingSoon: true }, + { type: 'link', id: 'content', icon: 'pi-file-edit', labelKey: 'adminShell.nav.content', absolutePath: ['edit', 'static-pages'] }, + { type: 'link', id: 'media', icon: 'pi-images', labelKey: 'adminShell.nav.mediaLibrary', path: ['media'] }, + { type: 'link', id: 'marketplace-builder', icon: 'pi-sitemap', labelKey: 'adminShell.nav.marketplaceBuilder', absolutePath: ['edit', 'general'] }, + { type: 'link', id: 'users', icon: 'pi-users', labelKey: 'adminShell.nav.users', path: ['users'] }, + { type: 'link', id: 'settings', icon: 'pi-cog', labelKey: 'adminShell.nav.settings', comingSoon: true }, + { type: 'link', id: 'monitoring', icon: 'pi-desktop', labelKey: 'adminShell.nav.monitoring', path: ['monitoring'] }, + { type: 'link', id: 'analytics', icon: 'pi-chart-line', labelKey: 'adminShell.nav.analytics', path: ['analytics'] }, +]; + +export const ADMIN_NAV_BOTTOM: AdminNavEntry[] = [ + { type: 'link', id: 'documentation', icon: 'pi-book', labelKey: 'adminShell.nav.documentation', comingSoon: true }, + { type: 'link', id: 'help', icon: 'pi-question-circle', labelKey: 'adminShell.nav.help', comingSoon: true }, + { type: 'action', id: 'logout', icon: 'pi-sign-out', labelKey: 'adminShell.nav.logout' }, +]; + +export interface AdminBreadcrumbEntry { + labelKey: string; + /** Segments appended to /:lang/backoffice/. Omitted on the last (current) crumb. */ + path?: string[]; +} + +export interface AdminPageData { + titleKey: string; + descriptionKey: string; + breadcrumb: AdminBreadcrumbEntry[]; +} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 22e8288..dbc4701 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -960,4 +960,61 @@ export const en: Translations = { topProductsEmptyTitle: 'No product sales in this period', topProductsEmptyDescription: 'Try a wider date range.', }, + adminShell: { + skipToContent: 'Skip to content', + openSidebar: 'Open menu', + closeSidebar: 'Close menu', + collapseSidebar: 'Collapse menu', + expandSidebar: 'Expand menu', + nav: { + dashboard: 'Dashboard', + catalogGroup: 'Catalog', + products: 'Products', + categories: 'Categories', + orders: 'Orders', + transactions: 'Transactions', + reviews: 'Reviews', + reports: 'Reports', + content: 'Content', + mediaLibrary: 'Media Library', + marketplaceBuilder: 'Marketplace Builder', + users: 'Users', + settings: 'Settings', + monitoring: 'Monitoring', + analytics: 'Analytics', + documentation: 'Documentation', + help: 'Help', + logout: 'Log out', + comingSoonBadge: 'Soon', + }, + topbar: { + searchLabel: 'Search the admin panel', + searchPlaceholder: 'Search products, orders, pages...', + notifications: 'Notifications', + notificationsEmpty: 'No new notifications', + account: 'Admin account', + tenantSelector: 'Store', + tenantSelectorComingSoon: 'Switching between stores is coming soon', + quickPublish: 'Publish', + quickPublishComingSoon: 'Quick publish is coming soon', + }, + breadcrumbHome: 'Admin', + pages: { + dashboard: { title: 'Dashboard', description: 'An overview of your marketplace' }, + products: { title: 'Products', description: 'Manage product listings and their visibility' }, + productCreate: { title: 'New product', description: 'Fill in the details to add a product to the catalog' }, + productEdit: { title: 'Edit product', description: 'Change this product\'s details' }, + productDuplicate: { title: 'Duplicate product', description: 'Create a new product based on an existing one' }, + categories: { title: 'Categories', description: 'Build your catalog\'s category tree' }, + categoryCreate: { title: 'New category', description: 'Add a category to the catalog tree' }, + categoryEdit: { title: 'Edit category', description: 'Change this category\'s details' }, + orders: { title: 'Orders', description: 'View and process customer orders' }, + orderDetail: { title: 'Order', description: 'Order details and status' }, + transactions: { title: 'Transactions', description: 'Payments, refunds, and fraud review' }, + media: { title: 'Media Library', description: 'Images and files for products and pages' }, + users: { title: 'Users', description: 'Admin team members and permissions' }, + monitoring: { title: 'Monitoring', description: 'Queue, webhook, and system event status' }, + analytics: { title: 'Analytics', description: 'Sales, products, and shopper behavior' }, + }, + }, }; diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 003379f..adf0fe0 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -955,4 +955,61 @@ export const hy: Translations = { topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա', topProductsEmptyDescription: 'Փորձեք ընտրել ավելի լայն ամսաթվերի միջակայք։', }, + adminShell: { + skipToContent: 'Անցնել բովանդակությանը', + openSidebar: 'Բացել ընտրացանկը', + closeSidebar: 'Փակել ընտրացանկը', + collapseSidebar: 'Ծալել ընտրացանկը', + expandSidebar: 'Ընդարձակել ընտրացանկը', + nav: { + dashboard: 'Կառավարման վահանակ', + catalogGroup: 'Կատալոգ', + products: 'Ապրանքներ', + categories: 'Կատեգորիաներ', + orders: 'Պատվերներ', + transactions: 'Գործարքներ', + reviews: 'Կարծիքներ', + reports: 'Հաշվետվություններ', + content: 'Բովանդակություն', + mediaLibrary: 'Մեդիադարան', + marketplaceBuilder: 'Մարկետփլեյսի կոնստրուկտոր', + users: 'Օգտատերեր', + settings: 'Կարգավորումներ', + monitoring: 'Մոնիտորինգ', + analytics: 'Վերլուծություն', + documentation: 'Փաստաթղթեր', + help: 'Օգնություն', + logout: 'Ելք', + comingSoonBadge: 'Շուտով', + }, + topbar: { + searchLabel: 'Որոնում ադմին վահանակում', + searchPlaceholder: 'Փնտրել ապրանքներ, պատվերներ, էջեր...', + notifications: 'Ծանուցումներ', + notificationsEmpty: 'Նոր ծանուցումներ չկան', + account: 'Ադմինիստրատորի հաշիվ', + tenantSelector: 'Խանութ', + tenantSelectorComingSoon: 'Խանութների միջև անցումը շուտով կհասանելի լինի', + quickPublish: 'Հրապարակել', + quickPublishComingSoon: 'Արագ հրապարակումը շուտով կհասանելի լինի', + }, + breadcrumbHome: 'Ադմին վահանակ', + pages: { + dashboard: { title: 'Կառավարման վահանակ', description: 'Ձեր մարկետփլեյսի ընդհանուր վիճակը' }, + products: { title: 'Ապրանքներ', description: 'Կառավարեք ապրանքների քարտերը և տեսանելիությունը' }, + productCreate: { title: 'Նոր ապրանք', description: 'Լրացրեք տվյալները՝ կատալոգում ապրանք ավելացնելու համար' }, + productEdit: { title: 'Ապրանքի խմբագրում', description: 'Փոփոխեք ապրանքի տվյալները' }, + productDuplicate: { title: 'Ապրանքի կրկնօրինակ', description: 'Ստեղծեք նոր ապրանք առկա ապրանքի հիման վրա' }, + categories: { title: 'Կատեգորիաներ', description: 'Կառուցեք կատալոգի կատեգորիաների ծառը' }, + categoryCreate: { title: 'Նոր կատեգորիա', description: 'Ավելացրեք կատեգորիա կատալոգի ծառում' }, + categoryEdit: { title: 'Կատեգորիայի խմբագրում', description: 'Փոփոխեք կատեգորիայի տվյալները' }, + orders: { title: 'Պատվերներ', description: 'Դիտեք և մշակեք գնորդների պատվերները' }, + orderDetail: { title: 'Պատվեր', description: 'Պատվերի մանրամասներն ու կարգավիճակը' }, + transactions: { title: 'Գործարքներ', description: 'Վճարումներ, վերադարձներ և կեղծիքի ստուգում' }, + media: { title: 'Մեդիադարան', description: 'Ապրանքների և էջերի պատկերներ ու ֆայլեր' }, + users: { title: 'Օգտատերեր', description: 'Ադմինիստրատորների թիմը և իրավունքները' }, + monitoring: { title: 'Մոնիտորինգ', description: 'Հերթերի, webhook-ների և համակարգային իրադարձությունների վիճակը' }, + analytics: { title: 'Վերլուծություն', description: 'Վաճառքներ, ապրանքներ և գնորդների վարքագիծ' }, + }, + }, }; diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index bf26a79..31239c2 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -955,4 +955,61 @@ export const ru: Translations = { topProductsEmptyTitle: 'Нет продаж товаров за этот период', topProductsEmptyDescription: 'Попробуйте выбрать более широкий диапазон дат.', }, + adminShell: { + skipToContent: 'Перейти к содержимому', + openSidebar: 'Открыть меню', + closeSidebar: 'Закрыть меню', + collapseSidebar: 'Свернуть меню', + expandSidebar: 'Развернуть меню', + nav: { + dashboard: 'Панель управления', + catalogGroup: 'Каталог', + products: 'Товары', + categories: 'Категории', + orders: 'Заказы', + transactions: 'Транзакции', + reviews: 'Отзывы', + reports: 'Отчёты', + content: 'Контент', + mediaLibrary: 'Медиатека', + marketplaceBuilder: 'Конструктор маркетплейса', + users: 'Пользователи', + settings: 'Настройки', + monitoring: 'Мониторинг', + analytics: 'Аналитика', + documentation: 'Документация', + help: 'Помощь', + logout: 'Выйти', + comingSoonBadge: 'Скоро', + }, + topbar: { + searchLabel: 'Поиск по админ-панели', + searchPlaceholder: 'Искать товары, заказы, страницы...', + notifications: 'Уведомления', + notificationsEmpty: 'Новых уведомлений нет', + account: 'Аккаунт администратора', + tenantSelector: 'Магазин', + tenantSelectorComingSoon: 'Переключение между магазинами скоро появится', + quickPublish: 'Опубликовать', + quickPublishComingSoon: 'Быстрая публикация скоро появится', + }, + breadcrumbHome: 'Админ-панель', + pages: { + dashboard: { title: 'Панель управления', description: 'Обзор состояния вашего маркетплейса' }, + products: { title: 'Товары', description: 'Управляйте карточками товаров и их видимостью' }, + productCreate: { title: 'Новый товар', description: 'Заполните карточку, чтобы добавить товар в каталог' }, + productEdit: { title: 'Редактирование товара', description: 'Измените данные товара' }, + productDuplicate: { title: 'Копия товара', description: 'Создайте новый товар на основе существующего' }, + categories: { title: 'Категории', description: 'Постройте дерево категорий каталога' }, + categoryCreate: { title: 'Новая категория', description: 'Добавьте категорию в дерево каталога' }, + categoryEdit: { title: 'Редактирование категории', description: 'Измените данные категории' }, + orders: { title: 'Заказы', description: 'Просматривайте и обрабатывайте заказы покупателей' }, + orderDetail: { title: 'Заказ', description: 'Детали и статус заказа' }, + transactions: { title: 'Транзакции', description: 'Платежи, возвраты и проверка мошенничества' }, + media: { title: 'Медиатека', description: 'Изображения и файлы для товаров и страниц' }, + users: { title: 'Пользователи', description: 'Команда администраторов и права доступа' }, + monitoring: { title: 'Мониторинг', description: 'Состояние очередей, вебхуков и системных событий' }, + analytics: { title: 'Аналитика', description: 'Продажи, товары и поведение покупателей' }, + }, + }, }; diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index ff4d576..c851308 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -967,4 +967,61 @@ export interface Translations { topProductsEmptyTitle: string; topProductsEmptyDescription: string; }; + adminShell: { + skipToContent: string; + openSidebar: string; + closeSidebar: string; + collapseSidebar: string; + expandSidebar: string; + nav: { + dashboard: string; + catalogGroup: string; + products: string; + categories: string; + orders: string; + transactions: string; + reviews: string; + reports: string; + content: string; + mediaLibrary: string; + marketplaceBuilder: string; + users: string; + settings: string; + monitoring: string; + analytics: string; + documentation: string; + help: string; + logout: string; + comingSoonBadge: string; + }; + topbar: { + searchLabel: string; + searchPlaceholder: string; + notifications: string; + notificationsEmpty: string; + account: string; + tenantSelector: string; + tenantSelectorComingSoon: string; + quickPublish: string; + quickPublishComingSoon: string; + }; + breadcrumbHome: string; + pages: { + dashboard: { title: string; description: string }; + products: { title: string; description: string }; + productCreate: { title: string; description: string }; + productEdit: { title: string; description: string }; + productDuplicate: { title: string; description: string }; + categories: { title: string; description: string }; + categoryCreate: { title: string; description: string }; + categoryEdit: { title: string; description: string }; + orders: { title: string; description: string }; + orderDetail: { title: string; description: string }; + transactions: { title: string; description: string }; + media: { title: string; description: string }; + users: { title: string; description: string }; + monitoring: { title: string; description: string }; + analytics: { title: string; description: string }; + }; + }; }