fix(catalog): filter UX and tablet layout
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
This commit is contained in:
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'Միացված',
|
||||||
|
|||||||
@@ -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: 'Включено',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user