diff --git a/src/app/core/backoffice/backoffice-data.service.ts b/src/app/core/backoffice/backoffice-data.service.ts index 78bf6a7..43b96b6 100644 --- a/src/app/core/backoffice/backoffice-data.service.ts +++ b/src/app/core/backoffice/backoffice-data.service.ts @@ -1,17 +1,46 @@ import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; +import { Observable, throwError } from 'rxjs'; +import { catchError, shareReplay } from 'rxjs/operators'; import { ProductCardConfig, CategoryCardConfig } from '../../shared/models/ui'; import { BACKOFFICE_DATA_PROVIDER } from './backoffice-data-provider.token'; +/** + * Single access point for backoffice catalog data. Requests are cached with + * shareReplay so the many gateways that read products/categories (products, + * categories, moderation, dashboard metrics) share one in-flight request per + * endpoint instead of each firing their own. A failed request clears the cache + * so the next call retries instead of replaying the error forever. + */ @Injectable({ providedIn: 'root' }) export class BackofficeDataService { private readonly provider = inject(BACKOFFICE_DATA_PROVIDER); + private products$: Observable | null = null; + private categories$: Observable | null = null; + loadProducts(): Observable { - return this.provider.loadProducts(); + if (!this.products$) { + this.products$ = this.provider.loadProducts().pipe( + catchError(error => { + this.products$ = null; + return throwError(() => error); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ); + } + return this.products$; } loadCategories(): Observable { - return this.provider.loadCategories(); + if (!this.categories$) { + this.categories$ = this.provider.loadCategories().pipe( + catchError(error => { + this.categories$ = null; + return throwError(() => error); + }), + shareReplay({ bufferSize: 1, refCount: false }), + ); + } + return this.categories$; } } diff --git a/src/app/features/admin/categories/services/admin-categories-local.gateway.ts b/src/app/features/admin/categories/services/admin-categories-local.gateway.ts index 95456f0..70c06d9 100644 --- a/src/app/features/admin/categories/services/admin-categories-local.gateway.ts +++ b/src/app/features/admin/categories/services/admin-categories-local.gateway.ts @@ -79,7 +79,10 @@ export class AdminCategoriesLocalGateway implements AdminCategoriesGateway { return; } - const categories = await new Promise(resolve => this.backofficeData.loadCategories().subscribe(value => resolve(value))); + // Empty list on transport failure - never leave the promise unsettled. + const categories = await new Promise(resolve => + this.backofficeData.loadCategories().subscribe({ next: value => resolve(value), error: () => resolve([]) }) + ); this.cache = categories.map(category => this.toAdminCategory(category)); } diff --git a/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts b/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts index dc21c08..b9533d0 100644 --- a/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts +++ b/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts @@ -129,7 +129,10 @@ export class AdminModerationLocalGateway implements AdminModerationGateway { if (this.cache && this.reportsCache) { return; } - this.products = await new Promise(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value))); + // Empty list on transport failure - never leave the promise unsettled. + this.products = await new Promise(resolve => + this.backofficeData.loadProducts().subscribe({ next: value => resolve(value), error: () => resolve([]) }) + ); this.cache = Array.from({ length: SEED_COUNT }, (_, index) => this.seedReview(index)); this.reportsCache = this.seedReports(); } diff --git a/src/app/features/admin/monitoring/services/admin-monitoring-gateway.interface.ts b/src/app/features/admin/monitoring/services/admin-monitoring-gateway.interface.ts new file mode 100644 index 0000000..70c9a99 --- /dev/null +++ b/src/app/features/admin/monitoring/services/admin-monitoring-gateway.interface.ts @@ -0,0 +1,14 @@ +import { Observable } from 'rxjs'; +import { AdminMonitoringEvent, AdminMonitoringEventFilters, AdminQueue, AdminWebhookDelivery } from '../models/admin-monitoring.model'; + +/** + * Contract for monitoring data sources. AdminMonitoringLocalGateway implements + * this today; a future HTTP gateway implements the same shape so the facade + * and page stay untouched when the backend lands (same pattern as + * AdminProductsGateway / AdminOrdersGateway). + */ +export interface AdminMonitoringGateway { + loadEvents(filters: AdminMonitoringEventFilters): Observable; + loadQueues(): Observable; + loadWebhooks(): Observable; +} diff --git a/src/app/features/admin/monitoring/services/admin-monitoring-local.gateway.ts b/src/app/features/admin/monitoring/services/admin-monitoring-local.gateway.ts index 1b31f5e..26aea0e 100644 --- a/src/app/features/admin/monitoring/services/admin-monitoring-local.gateway.ts +++ b/src/app/features/admin/monitoring/services/admin-monitoring-local.gateway.ts @@ -9,13 +9,14 @@ import { AdminQueue, AdminWebhookDelivery, } from '../models/admin-monitoring.model'; +import { AdminMonitoringGateway } from './admin-monitoring-gateway.interface'; const CATEGORIES: AdminMonitoringCategory[] = ['audit', 'security', 'login', 'failed_login', 'api', 'error', 'warning']; const ACTORS = ['karen@dexar.market', 'anna@dexar.market', 'system', 'unknown']; const EVENT_COUNT = 40; @Injectable({ providedIn: 'root' }) -export class AdminMonitoringLocalGateway { +export class AdminMonitoringLocalGateway implements AdminMonitoringGateway { private events: AdminMonitoringEvent[] | null = null; loadEvents(filters: AdminMonitoringEventFilters): Observable { diff --git a/src/app/features/admin/products/services/admin-products-local.gateway.ts b/src/app/features/admin/products/services/admin-products-local.gateway.ts index 531f5a7..93359ff 100644 --- a/src/app/features/admin/products/services/admin-products-local.gateway.ts +++ b/src/app/features/admin/products/services/admin-products-local.gateway.ts @@ -113,10 +113,18 @@ export class AdminProductsLocalGateway implements AdminProductsGateway { return; } - const products = await new Promise(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value))); + // Resolve with an empty list on transport failure so callers see an empty + // state instead of a promise that never settles (which froze every admin + // list in a permanent loading state when the API was unreachable). + const products = await new Promise(resolve => + this.backofficeData.loadProducts().subscribe({ next: value => resolve(value), error: () => resolve([]) }) + ); const categories = await new Promise(resolve => this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: false }) - .subscribe(value => resolve(value.map(category => ({ id: category.id, title: category.title })))) + .subscribe({ + next: value => resolve(value.map(category => ({ id: category.id, title: category.title }))), + error: () => resolve([]), + }) ); this.productsCache = products.map((product, index) => this.toAdminProduct(product, categories[index % Math.max(1, categories.length)]?.id ?? 'cat-001')); this.categoriesCache = categories; diff --git a/src/app/features/admin/shell/admin-layout.component.ts b/src/app/features/admin/shell/admin-layout.component.ts index b5b1cce..f84fd86 100644 --- a/src/app/features/admin/shell/admin-layout.component.ts +++ b/src/app/features/admin/shell/admin-layout.component.ts @@ -130,7 +130,10 @@ export class AdminLayoutComponent { while (leaf.firstChild) { leaf = leaf.firstChild; } - const data = leaf.snapshot.data as { + // 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 { titleKey?: string; descriptionKey?: string; breadcrumb?: AdminBreadcrumbEntry[]; diff --git a/src/app/features/website/catalog/containers/catalog-container.component.ts b/src/app/features/website/catalog/containers/catalog-container.component.ts index 8111e2a..affb40a 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.ts +++ b/src/app/features/website/catalog/containers/catalog-container.component.ts @@ -934,7 +934,9 @@ export class CatalogContainerComponent { return numeric; } - const categories = await new Promise(resolve => this.categoryFacade.getAllCategories().subscribe(value => resolve(value))); + const categories = await new Promise(resolve => + this.categoryFacade.getAllCategories().subscribe({ next: value => resolve(value), error: () => resolve([]) }) + ); const normalized = categoryToken.trim().toLowerCase(); const matched = categories.find(category => category.title.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-') === normalized); return matched?.id ?? null;