5 Commits

Author SHA1 Message Date
sdarbinyan
6231128288 fix: static-page loadByKey/loadByPath had no error handler, infinite spinner on failure
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
resolveByKey/resolveByRoute subscribed with only a next callback -
a resolver failure left loading=true forever with no error branch to
recover from. Added an error signal, error subscribe handler, and a
distinct error state UI (separate from the existing 404 not-found
state) with a way back home.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:56:25 +04:00
sdarbinyan
fd8e7e1b28 fix: DataSourceResolverService.resolve() had no catchError
A category/product facade error propagated through switchMap
uncaught, erroring the shared widget stream (shareReplay) in
WidgetHostService with no fallback - the widget just silently failed
to render, and the error stayed cached for every later subscriber.

Added catchError falling back to an empty { section, settings } shape,
same pattern as the widget-manifest fetch (falls back to { widgets: [] }
on any error, never throws to the UI).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:54:39 +04:00
sdarbinyan
000bb78112 fix: no error feedback on failed save/delete/role-change, editors navigated away before save result was known
Products/Categories saveDraft() and deleteOne(), and Users
setRole()/setStatus()/invite(), had no error handler at all - a
failed mutation was completely silent.

Also found and fixed the same premature-navigation bug as the Phase 1
cart fix: both product and category editor pages called
router.navigate() immediately after facade.saveDraft(), before the
save had resolved - so even after adding error feedback, the user
would already be gone from the page before it could show. saveDraft()
now takes an onSuccess callback and only the page navigates on actual
success; on failure it stays put and shows a themed error dialog.

Added a mutationError signal to all three facades and a themed
app-dialog error alert on the products/categories editor + list pages
and the users page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:53:12 +04:00
sdarbinyan
d3d6632375 fix: reports page never read facade.error(), silently showed 0/0
AdminAnalyticsFacade.error() already existed but the reports page
template never checked it - a load failure just rendered the summary
cards with default/zero values, looking like a legitimate empty
report instead of a failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:46:17 +04:00
sdarbinyan
1e84d67e24 fix: load errors swallowed to empty array across 5 admin facades
Orders/Products/Categories/Customers/Transactions loadList() caught
errors by silently clearing the list to [] with no error state - an
API failure looked identical to a genuine 'no results' empty state.

Added an error signal to each facade (set on failure, cleared on
retry) and an error branch in each list page/component, distinct from
both loading and the real empty state.

Order and Customer detail (loadDetail) had it worse: no error handler
at all, so a failure just left the page on 'Loading...' forever with
nothing to retry or navigate away with. Added selectedLoading/
selectedError to both facades and an error screen with a back button
to both detail pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:44:30 +04:00
25 changed files with 196 additions and 27 deletions

View File

@@ -64,6 +64,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (error) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (treeRows.length === 0) {
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate">
<span slot="actions">

View File

@@ -51,6 +51,7 @@ export class AdminCategoriesListComponent {
@Input() flatCategories: AdminCategory[] = [];
@Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean };
@Input() loading = false;
@Input() error: string | null = null;
@Input() viewMode: AdminCategoriesViewMode = 'tree';
@Input() density: AdminCategoriesDensity = 'comfortable';
@Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS];

View File

@@ -131,11 +131,13 @@ export class AdminCategoriesFacade {
readonly filters = signal<AdminCategoryListFilters>({ search: '', visibility: 'all', includeDeleted: false });
readonly categories = signal<AdminCategory[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly draft = signal<AdminCategory | null>(null);
readonly editorMode = signal<AdminCategoryEditorMode>('create');
readonly dirty = signal(false);
readonly slugTaken = signal(false);
readonly slugCheckError = signal(false);
readonly mutationError = signal<string | null>(null);
private savedSnapshot: string | null = null;
private draftStorageKey: string | null = null;
@@ -174,6 +176,7 @@ export class AdminCategoriesFacade {
*/
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
next: categories => {
this.categories.set(categories);
@@ -182,6 +185,7 @@ export class AdminCategoriesFacade {
error: () => {
this.categories.set([]);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}
@@ -323,10 +327,11 @@ export class AdminCategoriesFacade {
});
}
saveDraft(publish: boolean): void {
saveDraft(publish: boolean, onSuccess?: () => void): void {
const draft = this.draft();
if (!draft || this.slugTaken() || this.slugCheckError()) return;
this.mutationError.set(null);
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() };
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);
@@ -339,7 +344,9 @@ export class AdminCategoriesFacade {
this.savedSnapshot = JSON.stringify(saved);
this.dirty.set(false);
this.loadList();
}
onSuccess?.();
},
error: () => this.mutationError.set('common.errorDescription')
});
}
@@ -354,7 +361,11 @@ export class AdminCategoriesFacade {
}
deleteOne(id: string): void {
this.gateway.deleteCategory(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } });
this.mutationError.set(null);
this.gateway.deleteCategory(id).pipe(take(1)).subscribe({
next: () => { this.loadList(); this.loadDashboardStats(); },
error: () => this.mutationError.set('common.errorDescription')
});
}
restoreOne(id: string): void {

View File

@@ -19,6 +19,7 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
[allCategories]="facade.categories()"
[filters]="facade.filters()"
[loading]="facade.loading()"
[error]="facade.error()"
[viewMode]="facade.viewMode()"
[density]="facade.density()"
[visibleColumns]="facade.visibleColumns()"
@@ -70,6 +71,14 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
<app-button variant="primary" (click)="deleteBlockedMessage.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}
@if (facade.mutationError()) {
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
<p>{{ 'common.errorDescription' | translate }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}`,
changeDetection: ChangeDetectionStrategy.OnPush
})

View File

@@ -4,12 +4,22 @@ import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
import { AdminCategoryFormComponent } from '../components/admin-category-form.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({
selector: 'app-admin-category-editor-page',
standalone: true,
imports: [AdminCategoryFormComponent, TranslatePipe],
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [slugCheckError]="facade.slugCheckError()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
imports: [AdminCategoryFormComponent, TranslatePipe, DialogComponent, ButtonComponent],
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [slugCheckError]="facade.slugCheckError()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}
@if (facade.mutationError()) {
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
<p>{{ 'common.errorDescription' | translate }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}`,
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
changeDetection: ChangeDetectionStrategy.OnPush
})
@@ -41,8 +51,9 @@ export class AdminCategoryEditorPageComponent {
}
save(publish: boolean): void {
this.facade.saveDraft(publish);
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
this.facade.saveDraft(publish, () => {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
});
}
private descendantIds(id: string): string[] {

View File

@@ -10,8 +10,11 @@ export class AdminCustomersFacade {
readonly customers = signal<AdminCustomer[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly search = signal('');
readonly selected = signal<AdminCustomer | null>(null);
readonly selectedLoading = signal(false);
readonly selectedError = signal<string | null>(null);
private buildCustomers(orders: AdminOrder[]): AdminCustomer[] {
const byEmail = new Map<string, AdminOrder[]>();
@@ -41,6 +44,7 @@ export class AdminCustomersFacade {
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => {
this.customers.set(this.buildCustomers(result.items));
@@ -49,16 +53,24 @@ export class AdminCustomersFacade {
error: () => {
this.customers.set([]);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}
loadDetail(email: string): void {
this.selectedLoading.set(true);
this.selectedError.set(null);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => {
const decoded = decodeURIComponent(email);
const customers = this.buildCustomers(result.items);
this.selected.set(customers.find(customer => customer.email === decoded) ?? null);
this.selectedLoading.set(false);
},
error: () => {
this.selectedLoading.set(false);
this.selectedError.set('common.errorDescription');
}
});
}

View File

@@ -48,6 +48,12 @@
<app-order-timeline [entries]="activity()" [showOrderNumber]="true" />
</section>
</main>
} @else if (facade.selectedError()) {
<div class="customer-detail-error" role="alert">
<p>{{ 'common.errorTitle' | translate }}</p>
<p>{{ 'common.errorDescription' | translate }}</p>
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminCustomers.back' | translate }}</app-button>
</div>
} @else {
<p>{{ 'common.loading' | translate }}</p>
}

View File

@@ -8,6 +8,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.filteredCustomers().length === 0) {
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
} @else {

View File

@@ -37,7 +37,10 @@ export class AdminOrdersFacade {
readonly orders = signal<AdminOrder[]>([]);
readonly total = signal(0);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly selected = signal<AdminOrder | null>(null);
readonly selectedLoading = signal(false);
readonly selectedError = signal<string | null>(null);
readonly viewMode = signal<AdminOrdersViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminOrdersViewMode) || 'table');
readonly density = signal<AdminOrdersDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminOrdersDensity) || 'comfortable');
@@ -76,6 +79,7 @@ export class AdminOrdersFacade {
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadOrders(this.filters()).pipe(take(1)).subscribe({
next: result => {
this.orders.set(result.items);
@@ -86,6 +90,7 @@ export class AdminOrdersFacade {
this.orders.set([]);
this.total.set(0);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}
@@ -96,7 +101,18 @@ export class AdminOrdersFacade {
}
loadDetail(id: string): void {
this.gateway.loadOrder(id).pipe(take(1)).subscribe({ next: order => this.selected.set(order) });
this.selectedLoading.set(true);
this.selectedError.set(null);
this.gateway.loadOrder(id).pipe(take(1)).subscribe({
next: order => {
this.selected.set(order);
this.selectedLoading.set(false);
},
error: () => {
this.selectedLoading.set(false);
this.selectedError.set('common.errorDescription');
}
});
}
setStatus(id: string, status: AdminOrderStatus): void {

View File

@@ -103,6 +103,12 @@
(confirmed)="confirmRefund()"
(cancelled)="pendingRefundId.set(null)" />
</main>
} @else if (facade.selectedError()) {
<div class="order-detail-error" role="alert">
<p>{{ 'common.errorTitle' | translate }}</p>
<p>{{ 'common.errorDescription' | translate }}</p>
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminOrders.back' | translate }}</app-button>
</div>
} @else {
<p>{{ 'common.loading' | translate }}</p>
}

View File

@@ -61,6 +61,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.orders().length === 0) {
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
} @else {

View File

@@ -78,6 +78,8 @@
}
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (error) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (products.length === 0) {
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate">
<span slot="actions">

View File

@@ -44,6 +44,7 @@ export class AdminProductsListComponent {
@Input() total = 0;
@Input() selectedIds: string[] = [];
@Input() loading = false;
@Input() error: string | null = null;
@Input() infiniteScroll = false;
@Input() viewMode: AdminProductsViewMode = 'table';
@Input() density: AdminProductsDensity = 'comfortable';

View File

@@ -91,6 +91,8 @@ export class AdminProductsFacade {
readonly infiniteScroll = signal(false);
readonly categories = signal<AdminProductCategoryOption[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly mutationError = signal<string | null>(null);
readonly selectedIds = signal<string[]>([]);
readonly draft = signal<AdminProduct | null>(null);
readonly dirty = signal(false);
@@ -100,6 +102,7 @@ export class AdminProductsFacade {
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({
next: result => {
this.products.set(result.items);
@@ -110,6 +113,7 @@ export class AdminProductsFacade {
this.products.set([]);
this.total.set(0);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}
@@ -302,18 +306,26 @@ export class AdminProductsFacade {
this.dirty.set(true);
}
saveDraft(): void {
saveDraft(onSuccess?: () => void): void {
const draft = this.draft();
if (!draft) return;
this.mutationError.set(null);
const request = this.editorMode() === 'create'
? this.gateway.createProduct(draft)
: this.gateway.updateProduct(draft);
request.pipe(take(1)).subscribe({ next: () => { this.dirty.set(false); this.loadList(); } });
request.pipe(take(1)).subscribe({
next: () => { this.dirty.set(false); this.loadList(); onSuccess?.(); },
error: () => this.mutationError.set('common.errorDescription')
});
}
deleteOne(id: string): void {
this.gateway.deleteProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
this.mutationError.set(null);
this.gateway.deleteProduct(id).pipe(take(1)).subscribe({
next: () => this.loadList(),
error: () => this.mutationError.set('common.errorDescription')
});
}
}

View File

@@ -4,12 +4,22 @@ import { AdminProductsFacade } from '../facade/admin-products.facade';
import { AdminProductFormComponent } from '../components/admin-product-form.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({
selector: 'app-admin-product-editor-page',
standalone: true,
imports: [AdminProductFormComponent, TranslatePipe],
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
imports: [AdminProductFormComponent, TranslatePipe, DialogComponent, ButtonComponent],
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}
@if (facade.mutationError()) {
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
<p>{{ 'common.errorDescription' | translate }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}`,
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
changeDetection: ChangeDetectionStrategy.OnPush
})
@@ -33,7 +43,8 @@ export class AdminProductEditorPageComponent {
}
save(): void {
this.facade.saveDraft();
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
this.facade.saveDraft(() => {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
});
}
}

View File

@@ -5,11 +5,13 @@ import { AdminProductsListComponent } from '../components/admin-products-list.co
import { LanguageService } from '../../../../services/language.service';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({
selector: 'app-admin-products-list-page',
standalone: true,
imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe],
imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe, DialogComponent, ButtonComponent],
template: `<app-admin-products-list
[products]="facade.products()"
[categories]="facade.categories()"
@@ -17,6 +19,7 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
[total]="facade.total()"
[selectedIds]="facade.selectedIds()"
[loading]="facade.loading()"
[error]="facade.error()"
[infiniteScroll]="facade.infiniteScroll()"
[viewMode]="facade.viewMode()"
[density]="facade.density()"
@@ -56,7 +59,15 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
[message]="'adminProducts.confirmBulkDelete' | translate"
[destructive]="true"
(confirmed)="confirmBulkDelete()"
(cancelled)="bulkDeleteConfirmOpen.set(false)" />`,
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
@if (facade.mutationError()) {
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
<p>{{ 'common.errorDescription' | translate }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminProductsListPageComponent {

View File

@@ -6,6 +6,8 @@
}
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else {
<div class="report-grid">
<div class="report-card">

View File

@@ -3,11 +3,12 @@ import { AdminAnalyticsFacade } from '../../analytics/facade/admin-analytics.fac
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-admin-reports-page',
standalone: true,
imports: [TranslatePipe, ButtonComponent, SkeletonComponent],
imports: [TranslatePipe, ButtonComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-reports-page.component.html',
styleUrls: ['./admin-reports-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -11,9 +11,11 @@ export class AdminTransactionsFacade {
readonly transactions = signal<AdminTransaction[]>([]);
readonly total = signal(0);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({
next: result => {
this.transactions.set(result.items);
@@ -24,6 +26,7 @@ export class AdminTransactionsFacade {
this.transactions.set([]);
this.total.set(0);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}

View File

@@ -22,6 +22,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.transactions().length === 0) {
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
} @else {

View File

@@ -12,6 +12,7 @@ export class AdminUsersFacade {
readonly invitations = signal<AdminInvitation[]>([]);
readonly loading = signal(false);
readonly error = signal(false);
readonly mutationError = signal<string | null>(null);
readonly sessionsTarget = signal<AdminUser | null>(null);
readonly sessions = signal<AdminSession[]>([]);
readonly auditTarget = signal<AdminUser | null>(null);
@@ -33,16 +34,28 @@ export class AdminUsersFacade {
}
setRole(userId: string, roleId: string): void {
this.gateway.setUserRole(userId, roleId).pipe(take(1)).subscribe({ next: () => this.loadAll() });
this.mutationError.set(null);
this.gateway.setUserRole(userId, roleId).pipe(take(1)).subscribe({
next: () => this.loadAll(),
error: () => this.mutationError.set('common.errorDescription')
});
}
setStatus(userId: string, status: AdminUserStatus): void {
this.gateway.setUserStatus(userId, status).pipe(take(1)).subscribe({ next: () => this.loadAll() });
this.mutationError.set(null);
this.gateway.setUserStatus(userId, status).pipe(take(1)).subscribe({
next: () => this.loadAll(),
error: () => this.mutationError.set('common.errorDescription')
});
}
invite(email: string, roleId: string, scope: AdminUserScope): void {
if (!email.trim()) return;
this.gateway.inviteUser(email.trim(), roleId, scope).pipe(take(1)).subscribe({ next: () => this.loadAll() });
this.mutationError.set(null);
this.gateway.inviteUser(email.trim(), roleId, scope).pipe(take(1)).subscribe({
next: () => this.loadAll(),
error: () => this.mutationError.set('common.errorDescription')
});
}
revokeInvitation(id: string): void {

View File

@@ -128,4 +128,13 @@
[destructive]="true"
(confirmed)="confirmSuspend()"
(cancelled)="pendingSuspendUserId.set(null)" />
@if (facade.mutationError()) {
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
<p>{{ 'common.errorDescription' | translate }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}
</section>

View File

@@ -6,6 +6,14 @@
<app-skeleton shape="text" height="16px" />
<app-skeleton shape="text" width="70%" height="16px" />
</section>
} @else if (error()) {
<section class="static-page__state">
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
<div slot="actions">
<app-button variant="primary" [routerLink]="homeRoute()">{{ 'staticPages.backHome' | translate }}</app-button>
</div>
</app-empty-state>
</section>
} @else if (notFound()) {
<section class="static-page__state">
<app-empty-state title="404" [description]="'staticPages.notFound' | translate">

View File

@@ -29,6 +29,7 @@ export class StaticPageComponent {
readonly loading = signal(true);
readonly notFound = signal(false);
readonly error = signal(false);
readonly title = signal('');
readonly homeRoute = signal('');
readonly dir = signal<'ltr' | 'rtl'>('ltr');
@@ -76,21 +77,32 @@ export class StaticPageComponent {
private loadByKey(key: string): void {
this.loading.set(true);
this.notFound.set(false);
this.error.set(false);
this.staticPageResolver.resolveByKey(key, this.languageService.currentLanguage()).subscribe(page => {
this.applyPage(page?.title ?? '', page?.html ?? '', !page);
this.staticPageResolver.resolveByKey(key, this.languageService.currentLanguage()).subscribe({
next: page => this.applyPage(page?.title ?? '', page?.html ?? '', !page),
error: () => this.applyError()
});
}
private loadByPath(path: string): void {
this.loading.set(true);
this.notFound.set(false);
this.error.set(false);
this.staticPageResolver.resolveByRoute(path, this.languageService.currentLanguage()).subscribe(page => {
this.applyPage(page?.title ?? '', page?.html ?? '', !page);
this.staticPageResolver.resolveByRoute(path, this.languageService.currentLanguage()).subscribe({
next: page => this.applyPage(page?.title ?? '', page?.html ?? '', !page),
error: () => this.applyError()
});
}
private applyError(): void {
this.title.set('');
this.safeHtml.set(this.sanitizer.bypassSecurityTrustHtml(''));
this.error.set(true);
this.loading.set(false);
}
private applyPage(title: string, html: string, notFound: boolean): void {
if (notFound) {
this.title.set('');

View File

@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { Observable, of, map, switchMap } from 'rxjs';
import { Observable, of, map, switchMap, catchError } from 'rxjs';
import { CategoryFacade } from '../../facades/platform/category.facade';
import { ProductFacade } from '../../facades/platform/product.facade';
import { LanguageService } from '../../services/language.service';
@@ -19,7 +19,11 @@ export class DataSourceResolverService {
resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
return this.widgetManifest.getWidget(widget.type).pipe(
switchMap((definition) => this.resolveByDefinition(definition, widget, section))
switchMap((definition) => this.resolveByDefinition(definition, widget, section)),
catchError((error) => {
console.error(`Failed to resolve widget data for ${widget.type}:${widget.id}`, error);
return of({ section, settings: {} });
})
);
}