fix(catalog): filter UX and tablet layout
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

This commit is contained in:
sdarbinyan
2026-07-09 02:57:48 +04:00
parent 6409a91cb0
commit 86de2cc45b
9 changed files with 108 additions and 18 deletions

View File

@@ -83,6 +83,36 @@ export class SearchFacade {
const categories = this.collectUnique(products.map(product => String(product.categoryID)).filter(Boolean)); const categories = this.collectUnique(products.map(product => String(product.categoryID)).filter(Boolean));
const subcategories = this.collectUnique(products.map(product => product.subcategoryId ?? '').filter(Boolean)); const subcategories = this.collectUnique(products.map(product => product.subcategoryId ?? '').filter(Boolean));
const attributes = this.collectUnique(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key))); const attributes = this.collectUnique(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key)));
const availabilityCounts = products.reduce<Record<string, number>>((acc, product) => {
const key = product.remainings === 'out'
? 'out-of-stock'
: product.remainings === 'low'
? 'low-stock'
: 'in-stock';
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {});
const availabilityBase = [
{ id: 'in-stock', label: this.translate.t('catalog.filterInStock'), value: 'in-stock', availability: 'in-stock' as const },
{ id: 'low-stock', label: this.translate.t('catalog.filterLowStock'), value: 'low-stock', availability: 'low-stock' as const },
{ id: 'out-of-stock', label: this.translate.t('catalog.filterOutOfStock'), value: 'out-of-stock', availability: 'out-of-stock' as const },
];
const availabilityOptions = availabilityBase
.filter(option => (availabilityCounts[option.value] ?? 0) > 0)
.map(option => ({ ...option, count: availabilityCounts[option.value] }));
const ratingOptions = this.collectUnique(
products
.map(product => String(Math.max(1, Math.min(5, Math.floor(product.rating || 0)))))
.filter(value => Number(value) > 0)
)
.map(value => Number(value))
.sort((a, b) => b - a)
.map(value => ({
id: `rating-${value}`,
label: this.translate.t('catalog.filterStars', { count: value }),
value: String(value),
rating: value,
}));
const groups: FilterGroup[] = [ const groups: FilterGroup[] = [
{ {
@@ -99,23 +129,14 @@ export class SearchFacade {
id: 'availability', id: 'availability',
label: this.translate.t('catalog.filterAvailability'), label: this.translate.t('catalog.filterAvailability'),
type: 'availability', type: 'availability',
options: [ options: availabilityOptions,
{ id: 'in-stock', label: this.translate.t('catalog.filterInStock'), value: 'in-stock', availability: 'in-stock' },
{ id: 'low-stock', label: this.translate.t('catalog.filterLowStock'), value: 'low-stock', availability: 'low-stock' },
{ id: 'out-of-stock', label: this.translate.t('catalog.filterOutOfStock'), value: 'out-of-stock', availability: 'out-of-stock' },
],
enabled: enabled.has('availability'), enabled: enabled.has('availability'),
}, },
{ {
id: 'rating', id: 'rating',
label: this.translate.t('catalog.filterRating'), label: this.translate.t('catalog.filterRating'),
type: 'rating', type: 'rating',
options: [5, 4, 3, 2, 1].map(value => ({ options: ratingOptions,
id: `rating-${value}`,
label: this.translate.t('catalog.filterStars', { count: value }),
value: String(value),
rating: value,
})),
enabled: enabled.has('rating'), enabled: enabled.has('rating'),
}, },
{ {
@@ -403,7 +424,12 @@ export class SearchFacade {
} }
private toColorHex(value: string): string { private toColorHex(value: string): string {
const normalized = value.toLowerCase(); const normalized = value.trim().toLowerCase();
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(normalized)) {
return normalized;
}
const palette: Record<string, string> = { const palette: Record<string, string> = {
black: '#111111', black: '#111111',
white: '#ffffff', white: '#ffffff',
@@ -418,8 +444,27 @@ export class SearchFacade {
grey: '#6b7280', grey: '#6b7280',
brown: '#92400e', brown: '#92400e',
beige: '#d6c6a8', beige: '#d6c6a8',
navy: '#1e3a8a',
cyan: '#06b6d4',
teal: '#0d9488',
lime: '#65a30d',
olive: '#4d7c0f',
maroon: '#7f1d1d',
silver: '#94a3b8',
gold: '#eab308',
}; };
return palette[normalized] ?? '#94a3b8'; if (palette[normalized]) {
return palette[normalized];
}
const tokens = normalized.split(/[\s,\-/]+/).filter(Boolean);
for (const token of tokens) {
if (palette[token]) {
return palette[token];
}
}
return '#94a3b8';
} }
} }

View File

@@ -4,7 +4,7 @@
</div> </div>
@for (filter of definitions; track filter.id) { @for (filter of definitions; track filter.id) {
@if (filter.enabled) { @if (shouldRenderFilter(filter)) {
<section class="filter-group"> <section class="filter-group">
<button <button
type="button" type="button"

View File

@@ -30,6 +30,24 @@ export class CatalogFiltersPanelComponent {
return this.collapsed()[filterId] ?? false; return this.collapsed()[filterId] ?? false;
} }
shouldRenderFilter(filter: FilterGroup): boolean {
if (!filter.enabled) {
return false;
}
if (filter.type === 'range' || filter.type === 'slider') {
const min = Number(filter.min ?? 0);
const max = Number(filter.max ?? 0);
return Number.isFinite(min) && Number.isFinite(max) && min < max;
}
if (filter.type === 'toggle') {
return true;
}
return (filter.options?.length ?? 0) > 1;
}
isSelected(filterId: string, optionValue: string): boolean { isSelected(filterId: string, optionValue: string): boolean {
return (this.state.values[filterId] ?? []).includes(optionValue); return (this.state.values[filterId] ?? []).includes(optionValue);
} }

View File

@@ -84,6 +84,13 @@
gap: 24px; gap: 24px;
} }
@media (max-width: 1024px) {
.catalog-products-section {
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
}
.catalog-left-panel { .catalog-left-panel {
position: sticky; position: sticky;
top: 16px; top: 16px;

View File

@@ -120,6 +120,7 @@ export class CatalogContainerComponent {
private dataSubscription?: Subscription; private dataSubscription?: Subscription;
private readonly backendFetchSize = 200; private readonly backendFetchSize = 200;
private pendingScrollY: number | null = null; private pendingScrollY: number | null = null;
private restoringScrollFromUrlSync = false;
private syncingUrl = false; private syncingUrl = false;
constructor() { constructor() {
@@ -522,14 +523,14 @@ export class CatalogContainerComponent {
layoutLabelKey(layout: CatalogLayoutMode): string { layoutLabelKey(layout: CatalogLayoutMode): string {
switch (layout) { switch (layout) {
case 'grid': case 'grid':
return 'catalog.layout.grid'; return 'catalog.layoutGrid';
case 'large-grid': case 'large-grid':
return 'catalog.layout.largeGrid'; return 'catalog.layoutLargeGrid';
case 'compact-grid': case 'compact-grid':
return 'catalog.layout.compactGrid'; return 'catalog.layoutCompactGrid';
case 'list': case 'list':
default: default:
return 'catalog.layout.list'; return 'catalog.layoutList';
} }
} }
@@ -691,12 +692,23 @@ export class CatalogContainerComponent {
private syncUrlFromState(): void { private syncUrlFromState(): void {
const queryParams = this.searchFacade.toQueryParams(this.toSearchState()); const queryParams = this.searchFacade.toQueryParams(this.toSearchState());
const scrollY = typeof window !== 'undefined' ? window.scrollY : null;
this.syncingUrl = true; this.syncingUrl = true;
void this.router.navigate([], { void this.router.navigate([], {
relativeTo: this.route, relativeTo: this.route,
queryParams, queryParams,
replaceUrl: true, replaceUrl: true,
}).finally(() => { }).finally(() => {
if (scrollY != null && typeof window !== 'undefined') {
this.restoringScrollFromUrlSync = true;
const target = Math.max(0, scrollY);
requestAnimationFrame(() => {
window.scrollTo({ top: target, behavior: 'auto' });
this.restoringScrollFromUrlSync = false;
});
}
this.syncingUrl = false; this.syncingUrl = false;
}); });
} }

View File

@@ -174,6 +174,8 @@ export const en: Translations = {
removeSavedSearch: 'Remove saved search', removeSavedSearch: 'Remove saved search',
resetFilters: 'Reset', resetFilters: 'Reset',
filtersTitle: 'Filters', filtersTitle: 'Filters',
from: 'From',
to: 'To',
minValue: 'Min {{value}}', minValue: 'Min {{value}}',
maxValue: 'Max {{value}}', maxValue: 'Max {{value}}',
enabled: 'Enabled', enabled: 'Enabled',

View File

@@ -174,6 +174,8 @@ export const hy: Translations = {
removeSavedSearch: 'Ջնջել պահպանված որոնումը', removeSavedSearch: 'Ջնջել պահպանված որոնումը',
resetFilters: 'Վերակայել', resetFilters: 'Վերակայել',
filtersTitle: 'Ֆիլտրեր', filtersTitle: 'Ֆիլտրեր',
from: 'Սկսած',
to: 'Մինչև',
minValue: 'Նվազ. {{value}}', minValue: 'Նվազ. {{value}}',
maxValue: 'Առավել. {{value}}', maxValue: 'Առավել. {{value}}',
enabled: 'Միացված', enabled: 'Միացված',

View File

@@ -174,6 +174,8 @@ export const ru: Translations = {
removeSavedSearch: 'Удалить сохраненный поиск', removeSavedSearch: 'Удалить сохраненный поиск',
resetFilters: 'Сбросить', resetFilters: 'Сбросить',
filtersTitle: 'Фильтры', filtersTitle: 'Фильтры',
from: 'От',
to: 'До',
minValue: 'От {{value}}', minValue: 'От {{value}}',
maxValue: 'До {{value}}', maxValue: 'До {{value}}',
enabled: 'Включено', enabled: 'Включено',

View File

@@ -172,6 +172,8 @@ export interface Translations {
removeSavedSearch: string; removeSavedSearch: string;
resetFilters: string; resetFilters: string;
filtersTitle: string; filtersTitle: string;
from: string;
to: string;
minValue: string; minValue: string;
maxValue: string; maxValue: string;
enabled: string; enabled: string;