feat(admin): build shared admin shell
Sidebar (Dashboard/Catalog group/Products/Categories/Orders/Transactions/
Reviews/Reports/Content/Media/Marketplace Builder/Users/Settings/
Monitoring/Analytics + Documentation/Help/Logout), sticky topbar
(breadcrumbs, page title/description, search, notifications, tenant
selector and quick-publish placeholders, current user), reserved
right-rail slot, scrollable content area. Desktop 280px sidebar, tablet
icon rail, mobile drawer with focus management and Escape-to-close.
Nav items without a built page (Reviews, Reports, Settings, Docs, Help)
render disabled with a coming-soon badge instead of dead links; Content
and Marketplace Builder route to the existing project-editor pages
(static-pages / general) rather than duplicating them.
All 15 /backoffice/** routes now render through AdminLayoutComponent;
the public storefront header/back-button/footer no longer render on
admin routes (app.ts/app.html gate on a new isAdminRoute signal).
Added the adminShell i18n namespace (ru/en/hy) for every new shell
string so this doesn't add to the existing untranslated-admin-UI gap
tracked in KNOWN-ISSUES.md.
Colors/type sizes follow DESIGN.md tokens; the two rgba() modal-scrim
values are a documented, intentional exception (neutral overlay,
not a themed token).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 00:12:19 +04:00
|
|
|
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<ElementRef<HTMLElement>>('drawer');
|
|
|
|
|
private readonly menuToggleEl = viewChild<ElementRef<HTMLElement>>('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<AdminBreadcrumbEntry[]>([]);
|
|
|
|
|
private readonly titleKeySignal = signal<string>('adminShell.pages.dashboard.title');
|
|
|
|
|
private readonly descriptionKeySignal = signal<string>('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<HTMLElement>('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<AdminNavEntry, { type: 'link' }>): 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;
|
|
|
|
|
}
|
refactor(core): prepare frontend for backend integration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Fix AdminLayoutComponent.readRouteData crash: leaf route snapshot/data is now optional with dashboard-title fallback, so admin deep links never crash the shell when route metadata is missing (root cause of every blocked browser test since Sprint 12)
- Never-settling promises fixed: all gateway ensureData() bridges (products, categories, moderation) and the catalog category resolver now resolve with an empty list on transport failure instead of hanging forever, so API outages surface as empty states with guidance rather than permanent skeletons plus global console errors
- Request de-duplication: BackofficeDataService caches products/categories with shareReplay - one in-flight request per endpoint shared by all consuming gateways (was 5+ duplicate requests per admin page load); failures clear the cache so the next call retries
- Gateway contract audit: all 8 admin gateways (products, categories, orders, customers/moderation, transactions, users, dashboard metrics, monitoring) now implement an explicit *Gateway interface - added the missing AdminMonitoringGateway; media already swaps via the abstract MediaRepository DI class
- Mock mode untouched: provider selection still flows through RuntimeProviderStrategyService/BACKOFFICE_DATA_PROVIDER
2026-07-19 07:53:47 +04:00
|
|
|
// Route metadata is optional: during the initial navigation (or for routes
|
|
|
|
|
// registered without `data`) the leaf snapshot may not exist yet. Fall back
|
|
|
|
|
// to dashboard defaults instead of crashing the whole admin shell.
|
|
|
|
|
const data = (leaf.snapshot?.data ?? {}) as {
|
feat(admin): build shared admin shell
Sidebar (Dashboard/Catalog group/Products/Categories/Orders/Transactions/
Reviews/Reports/Content/Media/Marketplace Builder/Users/Settings/
Monitoring/Analytics + Documentation/Help/Logout), sticky topbar
(breadcrumbs, page title/description, search, notifications, tenant
selector and quick-publish placeholders, current user), reserved
right-rail slot, scrollable content area. Desktop 280px sidebar, tablet
icon rail, mobile drawer with focus management and Escape-to-close.
Nav items without a built page (Reviews, Reports, Settings, Docs, Help)
render disabled with a coming-soon badge instead of dead links; Content
and Marketplace Builder route to the existing project-editor pages
(static-pages / general) rather than duplicating them.
All 15 /backoffice/** routes now render through AdminLayoutComponent;
the public storefront header/back-button/footer no longer render on
admin routes (app.ts/app.html gate on a new isAdminRoute signal).
Added the adminShell i18n namespace (ru/en/hy) for every new shell
string so this doesn't add to the existing untranslated-admin-UI gap
tracked in KNOWN-ISSUES.md.
Colors/type sizes follow DESIGN.md tokens; the two rgba() modal-scrim
values are a documented, intentional exception (neutral overlay,
not a themed token).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 00:12:19 +04:00
|
|
|
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 ?? []);
|
|
|
|
|
}
|
|
|
|
|
}
|