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

View File

@@ -51,6 +51,7 @@ export class AdminCategoriesListComponent {
@Input() flatCategories: AdminCategory[] = []; @Input() flatCategories: AdminCategory[] = [];
@Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean }; @Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean };
@Input() loading = false; @Input() loading = false;
@Input() error: string | null = null;
@Input() viewMode: AdminCategoriesViewMode = 'tree'; @Input() viewMode: AdminCategoriesViewMode = 'tree';
@Input() density: AdminCategoriesDensity = 'comfortable'; @Input() density: AdminCategoriesDensity = 'comfortable';
@Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS]; @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 filters = signal<AdminCategoryListFilters>({ search: '', visibility: 'all', includeDeleted: false });
readonly categories = signal<AdminCategory[]>([]); readonly categories = signal<AdminCategory[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly draft = signal<AdminCategory | null>(null); readonly draft = signal<AdminCategory | null>(null);
readonly editorMode = signal<AdminCategoryEditorMode>('create'); readonly editorMode = signal<AdminCategoryEditorMode>('create');
readonly dirty = signal(false); readonly dirty = signal(false);
readonly slugTaken = signal(false); readonly slugTaken = signal(false);
readonly slugCheckError = signal(false); readonly slugCheckError = signal(false);
readonly mutationError = signal<string | null>(null);
private savedSnapshot: string | null = null; private savedSnapshot: string | null = null;
private draftStorageKey: string | null = null; private draftStorageKey: string | null = null;
@@ -174,6 +176,7 @@ export class AdminCategoriesFacade {
*/ */
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({ this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
next: categories => { next: categories => {
this.categories.set(categories); this.categories.set(categories);
@@ -182,6 +185,7 @@ export class AdminCategoriesFacade {
error: () => { error: () => {
this.categories.set([]); this.categories.set([]);
this.loading.set(false); 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(); const draft = this.draft();
if (!draft || this.slugTaken() || this.slugCheckError()) return; if (!draft || this.slugTaken() || this.slugCheckError()) return;
this.mutationError.set(null);
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() }; 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); 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.savedSnapshot = JSON.stringify(saved);
this.dirty.set(false); this.dirty.set(false);
this.loadList(); this.loadList();
} onSuccess?.();
},
error: () => this.mutationError.set('common.errorDescription')
}); });
} }
@@ -354,7 +361,11 @@ export class AdminCategoriesFacade {
} }
deleteOne(id: string): void { 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 { restoreOne(id: string): void {

View File

@@ -19,6 +19,7 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
[allCategories]="facade.categories()" [allCategories]="facade.categories()"
[filters]="facade.filters()" [filters]="facade.filters()"
[loading]="facade.loading()" [loading]="facade.loading()"
[error]="facade.error()"
[viewMode]="facade.viewMode()" [viewMode]="facade.viewMode()"
[density]="facade.density()" [density]="facade.density()"
[visibleColumns]="facade.visibleColumns()" [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> <app-button variant="primary" (click)="deleteBlockedMessage.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div> </div>
</app-dialog> </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 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 { AdminCategoryFormComponent } from '../components/admin-category-form.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({ @Component({
selector: 'app-admin-category-editor-page', selector: 'app-admin-category-editor-page',
standalone: true, standalone: true,
imports: [AdminCategoryFormComponent, TranslatePipe], 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>}`, 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; }`], 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 changeDetection: ChangeDetectionStrategy.OnPush
}) })
@@ -41,8 +51,9 @@ export class AdminCategoryEditorPageComponent {
} }
save(publish: boolean): void { save(publish: boolean): void {
this.facade.saveDraft(publish); this.facade.saveDraft(publish, () => {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']); void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
});
} }
private descendantIds(id: string): string[] { private descendantIds(id: string): string[] {

View File

@@ -10,8 +10,11 @@ export class AdminCustomersFacade {
readonly customers = signal<AdminCustomer[]>([]); readonly customers = signal<AdminCustomer[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly search = signal(''); readonly search = signal('');
readonly selected = signal<AdminCustomer | null>(null); readonly selected = signal<AdminCustomer | null>(null);
readonly selectedLoading = signal(false);
readonly selectedError = signal<string | null>(null);
private buildCustomers(orders: AdminOrder[]): AdminCustomer[] { private buildCustomers(orders: AdminOrder[]): AdminCustomer[] {
const byEmail = new Map<string, AdminOrder[]>(); const byEmail = new Map<string, AdminOrder[]>();
@@ -41,6 +44,7 @@ export class AdminCustomersFacade {
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => { next: result => {
this.customers.set(this.buildCustomers(result.items)); this.customers.set(this.buildCustomers(result.items));
@@ -49,16 +53,24 @@ export class AdminCustomersFacade {
error: () => { error: () => {
this.customers.set([]); this.customers.set([]);
this.loading.set(false); this.loading.set(false);
this.error.set('common.errorDescription');
} }
}); });
} }
loadDetail(email: string): void { 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({ this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => { next: result => {
const decoded = decodeURIComponent(email); const decoded = decodeURIComponent(email);
const customers = this.buildCustomers(result.items); const customers = this.buildCustomers(result.items);
this.selected.set(customers.find(customer => customer.email === decoded) ?? null); 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" /> <app-order-timeline [entries]="activity()" [showOrderNumber]="true" />
</section> </section>
</main> </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 { } @else {
<p>{{ 'common.loading' | translate }}</p> <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" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.filteredCustomers().length === 0) { } @else if (facade.filteredCustomers().length === 0) {
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" /> <app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
} @else { } @else {

View File

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

View File

@@ -103,6 +103,12 @@
(confirmed)="confirmRefund()" (confirmed)="confirmRefund()"
(cancelled)="pendingRefundId.set(null)" /> (cancelled)="pendingRefundId.set(null)" />
</main> </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 { } @else {
<p>{{ 'common.loading' | translate }}</p> <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" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.orders().length === 0) { } @else if (facade.orders().length === 0) {
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" /> <app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
} @else { } @else {

View File

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

View File

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

View File

@@ -91,6 +91,8 @@ export class AdminProductsFacade {
readonly infiniteScroll = signal(false); readonly infiniteScroll = signal(false);
readonly categories = signal<AdminProductCategoryOption[]>([]); readonly categories = signal<AdminProductCategoryOption[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly mutationError = signal<string | null>(null);
readonly selectedIds = signal<string[]>([]); readonly selectedIds = signal<string[]>([]);
readonly draft = signal<AdminProduct | null>(null); readonly draft = signal<AdminProduct | null>(null);
readonly dirty = signal(false); readonly dirty = signal(false);
@@ -100,6 +102,7 @@ export class AdminProductsFacade {
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({ this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({
next: result => { next: result => {
this.products.set(result.items); this.products.set(result.items);
@@ -110,6 +113,7 @@ export class AdminProductsFacade {
this.products.set([]); this.products.set([]);
this.total.set(0); this.total.set(0);
this.loading.set(false); this.loading.set(false);
this.error.set('common.errorDescription');
} }
}); });
} }
@@ -302,18 +306,26 @@ export class AdminProductsFacade {
this.dirty.set(true); this.dirty.set(true);
} }
saveDraft(): void { saveDraft(onSuccess?: () => void): void {
const draft = this.draft(); const draft = this.draft();
if (!draft) return; if (!draft) return;
this.mutationError.set(null);
const request = this.editorMode() === 'create' const request = this.editorMode() === 'create'
? this.gateway.createProduct(draft) ? this.gateway.createProduct(draft)
: this.gateway.updateProduct(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 { 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 { AdminProductFormComponent } from '../components/admin-product-form.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({ @Component({
selector: 'app-admin-product-editor-page', selector: 'app-admin-product-editor-page',
standalone: true, standalone: true,
imports: [AdminProductFormComponent, TranslatePipe], 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>}`, 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; }`], 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 changeDetection: ChangeDetectionStrategy.OnPush
}) })
@@ -33,7 +43,8 @@ export class AdminProductEditorPageComponent {
} }
save(): void { save(): void {
this.facade.saveDraft(); this.facade.saveDraft(() => {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']); 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 { LanguageService } from '../../../../services/language.service';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({ @Component({
selector: 'app-admin-products-list-page', selector: 'app-admin-products-list-page',
standalone: true, standalone: true,
imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe], imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe, DialogComponent, ButtonComponent],
template: `<app-admin-products-list template: `<app-admin-products-list
[products]="facade.products()" [products]="facade.products()"
[categories]="facade.categories()" [categories]="facade.categories()"
@@ -17,6 +19,7 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
[total]="facade.total()" [total]="facade.total()"
[selectedIds]="facade.selectedIds()" [selectedIds]="facade.selectedIds()"
[loading]="facade.loading()" [loading]="facade.loading()"
[error]="facade.error()"
[infiniteScroll]="facade.infiniteScroll()" [infiniteScroll]="facade.infiniteScroll()"
[viewMode]="facade.viewMode()" [viewMode]="facade.viewMode()"
[density]="facade.density()" [density]="facade.density()"
@@ -56,7 +59,15 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
[message]="'adminProducts.confirmBulkDelete' | translate" [message]="'adminProducts.confirmBulkDelete' | translate"
[destructive]="true" [destructive]="true"
(confirmed)="confirmBulkDelete()" (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 changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AdminProductsListPageComponent { export class AdminProductsListPageComponent {

View File

@@ -6,6 +6,8 @@
} }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else { } @else {
<div class="report-grid"> <div class="report-grid">
<div class="report-card"> <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 { TranslatePipe } from '../../../../i18n/translate.pipe';
import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({ @Component({
selector: 'app-admin-reports-page', selector: 'app-admin-reports-page',
standalone: true, standalone: true,
imports: [TranslatePipe, ButtonComponent, SkeletonComponent], imports: [TranslatePipe, ButtonComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-reports-page.component.html', templateUrl: './admin-reports-page.component.html',
styleUrls: ['./admin-reports-page.component.scss'], styleUrls: ['./admin-reports-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -11,9 +11,11 @@ export class AdminTransactionsFacade {
readonly transactions = signal<AdminTransaction[]>([]); readonly transactions = signal<AdminTransaction[]>([]);
readonly total = signal(0); readonly total = signal(0);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({ this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({
next: result => { next: result => {
this.transactions.set(result.items); this.transactions.set(result.items);
@@ -24,6 +26,7 @@ export class AdminTransactionsFacade {
this.transactions.set([]); this.transactions.set([]);
this.total.set(0); this.total.set(0);
this.loading.set(false); 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" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.transactions().length === 0) { } @else if (facade.transactions().length === 0) {
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" /> <app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
} @else { } @else {

View File

@@ -12,6 +12,7 @@ export class AdminUsersFacade {
readonly invitations = signal<AdminInvitation[]>([]); readonly invitations = signal<AdminInvitation[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal(false); readonly error = signal(false);
readonly mutationError = signal<string | null>(null);
readonly sessionsTarget = signal<AdminUser | null>(null); readonly sessionsTarget = signal<AdminUser | null>(null);
readonly sessions = signal<AdminSession[]>([]); readonly sessions = signal<AdminSession[]>([]);
readonly auditTarget = signal<AdminUser | null>(null); readonly auditTarget = signal<AdminUser | null>(null);
@@ -33,16 +34,28 @@ export class AdminUsersFacade {
} }
setRole(userId: string, roleId: string): void { 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 { 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 { invite(email: string, roleId: string, scope: AdminUserScope): void {
if (!email.trim()) return; 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 { revokeInvitation(id: string): void {

View File

@@ -128,4 +128,13 @@
[destructive]="true" [destructive]="true"
(confirmed)="confirmSuspend()" (confirmed)="confirmSuspend()"
(cancelled)="pendingSuspendUserId.set(null)" /> (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> </section>

View File

@@ -6,6 +6,14 @@
<app-skeleton shape="text" height="16px" /> <app-skeleton shape="text" height="16px" />
<app-skeleton shape="text" width="70%" height="16px" /> <app-skeleton shape="text" width="70%" height="16px" />
</section> </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()) { } @else if (notFound()) {
<section class="static-page__state"> <section class="static-page__state">
<app-empty-state title="404" [description]="'staticPages.notFound' | translate"> <app-empty-state title="404" [description]="'staticPages.notFound' | translate">

View File

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

View File

@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core'; 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 { CategoryFacade } from '../../facades/platform/category.facade';
import { ProductFacade } from '../../facades/platform/product.facade'; import { ProductFacade } from '../../facades/platform/product.facade';
import { LanguageService } from '../../services/language.service'; import { LanguageService } from '../../services/language.service';
@@ -19,7 +19,11 @@ export class DataSourceResolverService {
resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> { resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
return this.widgetManifest.getWidget(widget.type).pipe( 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: {} });
})
); );
} }