Compare commits
3 Commits
bad3002006
...
00e5ce6b20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00e5ce6b20 | ||
|
|
a339a1c64e | ||
|
|
c9a80da7c3 |
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { take } from 'rxjs/operators';
|
import { forkJoin, Subject } from 'rxjs';
|
||||||
|
import { take, takeUntil } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
AdminAnalyticsDateRange,
|
AdminAnalyticsDateRange,
|
||||||
AdminAnalyticsSeriesPoint,
|
AdminAnalyticsSeriesPoint,
|
||||||
@@ -62,7 +63,13 @@ export class AdminAnalyticsFacade {
|
|||||||
|
|
||||||
readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info'));
|
readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info'));
|
||||||
|
|
||||||
|
private readonly cancelPreviousLoad$ = new Subject<void>();
|
||||||
|
|
||||||
load(): void {
|
load(): void {
|
||||||
|
// Cancel any still-in-flight previous load so a rapid setDateRange() double-call
|
||||||
|
// can't have a stale response overwrite a newer one.
|
||||||
|
this.cancelPreviousLoad$.next();
|
||||||
|
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(false);
|
this.error.set(false);
|
||||||
this.dashboardFacade.ensureLoaded();
|
this.dashboardFacade.ensureLoaded();
|
||||||
@@ -74,10 +81,13 @@ export class AdminAnalyticsFacade {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fail = (): void => { this.loading.set(false); this.error.set(true); };
|
forkJoin({
|
||||||
|
orderResult: this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }),
|
||||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
productResult: this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }),
|
||||||
next: orderResult => {
|
categories: this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }),
|
||||||
|
reviewResult: this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }),
|
||||||
|
}).pipe(take(1), takeUntil(this.cancelPreviousLoad$)).subscribe({
|
||||||
|
next: ({ orderResult, productResult, categories, reviewResult }) => {
|
||||||
const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000;
|
||||||
const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
|
const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
|
||||||
|
|
||||||
@@ -89,12 +99,6 @@ export class AdminAnalyticsFacade {
|
|||||||
const ordersCount = inRange.length;
|
const ordersCount = inRange.length;
|
||||||
const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size;
|
const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size;
|
||||||
|
|
||||||
this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
|
||||||
next: productResult => {
|
|
||||||
this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
|
|
||||||
next: categories => {
|
|
||||||
this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
|
||||||
next: reviewResult => {
|
|
||||||
const products = productResult.items;
|
const products = productResult.items;
|
||||||
const reviews = reviewResult.items;
|
const reviews = reviewResult.items;
|
||||||
|
|
||||||
@@ -116,16 +120,7 @@ export class AdminAnalyticsFacade {
|
|||||||
|
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
error: fail
|
error: () => { this.loading.set(false); this.error.set(true); }
|
||||||
});
|
|
||||||
},
|
|
||||||
error: fail
|
|
||||||
});
|
|
||||||
},
|
|
||||||
error: fail
|
|
||||||
});
|
|
||||||
},
|
|
||||||
error: fail
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, Output, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
|
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
|
||||||
|
const RANGE_DEBOUNCE_MS = 350;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-catalog-filters-panel',
|
selector: 'app-catalog-filters-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -11,12 +13,20 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
|||||||
styleUrls: ['./filters-panel.component.scss'],
|
styleUrls: ['./filters-panel.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class CatalogFiltersPanelComponent {
|
export class CatalogFiltersPanelComponent implements OnDestroy {
|
||||||
@Input() definitions: FilterGroup[] = [];
|
@Input() definitions: FilterGroup[] = [];
|
||||||
@Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
|
@Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
|
||||||
|
|
||||||
@Output() stateChange = new EventEmitter<SearchFilterState>();
|
@Output() stateChange = new EventEmitter<SearchFilterState>();
|
||||||
|
|
||||||
|
private readonly debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
for (const timer of this.debounceTimers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
readonly collapsed = signal<Record<string, boolean>>({});
|
readonly collapsed = signal<Record<string, boolean>>({});
|
||||||
|
|
||||||
toggleGroup(filterId: string): void {
|
toggleGroup(filterId: string): void {
|
||||||
@@ -79,8 +89,9 @@ export class CatalogFiltersPanelComponent {
|
|||||||
|
|
||||||
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
|
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
|
||||||
const value = rawValue.trim().length ? Number(rawValue) : undefined;
|
const value = rawValue.trim().length ? Number(rawValue) : undefined;
|
||||||
const current = this.state.ranges[filterId] ?? {};
|
|
||||||
|
|
||||||
|
this.debounce(`range:${filterId}:${key}`, () => {
|
||||||
|
const current = this.state.ranges[filterId] ?? {};
|
||||||
this.stateChange.emit({
|
this.stateChange.emit({
|
||||||
...this.state,
|
...this.state,
|
||||||
ranges: {
|
ranges: {
|
||||||
@@ -91,10 +102,13 @@ export class CatalogFiltersPanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSlider(filterId: string, value: string): void {
|
updateSlider(filterId: string, value: string): void {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
|
|
||||||
|
this.debounce(`slider:${filterId}`, () => {
|
||||||
this.stateChange.emit({
|
this.stateChange.emit({
|
||||||
...this.state,
|
...this.state,
|
||||||
ranges: {
|
ranges: {
|
||||||
@@ -105,6 +119,19 @@ export class CatalogFiltersPanelComponent {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debounces range/slider input so full catalog filter recompute doesn't run on every keystroke/drag event. */
|
||||||
|
private debounce(key: string, action: () => void): void {
|
||||||
|
const existing = this.debounceTimers.get(key);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
}
|
||||||
|
this.debounceTimers.set(key, setTimeout(() => {
|
||||||
|
this.debounceTimers.delete(key);
|
||||||
|
action();
|
||||||
|
}, RANGE_DEBOUNCE_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateToggle(filterId: string, checked: boolean): void {
|
updateToggle(filterId: string, checked: boolean): void {
|
||||||
|
|||||||
@@ -1,14 +1,42 @@
|
|||||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||||
import { TranslateService } from './translate.service';
|
import { TranslateService } from './translate.service';
|
||||||
|
import { LanguageService } from '../services/language.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stays impure (must re-run every CD cycle so a live language switch
|
||||||
|
* updates every binding without touching hundreds of `| translate`
|
||||||
|
* template call sites to pass the language explicitly as a pure-pipe
|
||||||
|
* argument). Memoized per-instance instead: repeat calls with the same
|
||||||
|
* key/params/language - the overwhelming majority of CD cycles, since
|
||||||
|
* nothing actually changed - hit a Map lookup instead of re-walking the
|
||||||
|
* translation tree and re-running the interpolation regex.
|
||||||
|
*/
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'translate',
|
name: 'translate',
|
||||||
pure: false,
|
pure: false,
|
||||||
})
|
})
|
||||||
export class TranslatePipe implements PipeTransform {
|
export class TranslatePipe implements PipeTransform {
|
||||||
private translateService = inject(TranslateService);
|
private translateService = inject(TranslateService);
|
||||||
|
private langService = inject(LanguageService);
|
||||||
|
|
||||||
|
private lastLang = '';
|
||||||
|
private readonly cache = new Map<string, string>();
|
||||||
|
|
||||||
transform(key: string, params?: Record<string, string | number>): string {
|
transform(key: string, params?: Record<string, string | number>): string {
|
||||||
return this.translateService.t(key, params);
|
const lang = this.langService.currentLanguage();
|
||||||
|
if (lang !== this.lastLang) {
|
||||||
|
this.lastLang = lang;
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = params ? `${key}::${JSON.stringify(params)}` : key;
|
||||||
|
const cached = this.cache.get(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.translateService.t(key, params);
|
||||||
|
this.cache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user