Розробка фільтрації товарів по параметрам для інтернет-магазину
Фільтрація — один з головних інструментів навігації в каталозі. Користувач з 500 ноутбуками в категорії не буде переглядати сторінки — він відфільтрує за брендом, RAM та діагоналлю. Погана фільтрація втрачає ці продажі. Хороша фільтрація — це faceted search: доступні значення фільтрів оновлюють залежно від уже вибраних, і користувач завжди знає, скільки товарів за кожним значенням.
Типи фільтрів
| Тип | UX-компонент | Приклад | Технічно |
|---|---|---|---|
| Множинний вибір | Чекбокси | Бренд: Apple, Samsung | WHERE brand IN (...) |
| Одиничний вибір | Radio buttons | Стан: новий/б/у | WHERE condition = ... |
| Числовий діапазон | Slider з двома ручками | Ціна: 5000–30000 руб. | WHERE price BETWEEN ... |
| Діапазон через інпути | Поля «від» та «до» | Діагональ: 13–15.6 дюйм | WHERE diagonal BETWEEN ... |
| Булевий | Переключатель | Тільки в наявності | WHERE stock > 0 |
| Рейтинг | Зірочки (≥N) | Рейтинг від 4 | WHERE rating >= 4 |
| Колір | Кольорові свотчи | Колір: чорний, срібло | WHERE color IN (...) |
Faceted search з SQL
Найпростіший підхід — фільтрація через PostgreSQL. Працює до ~100 000 товарів при правильній індексації.
-- Основний запит з фільтрами
SELECT p.* FROM products p
WHERE p.category_id = :cat
AND (:brands IS NULL OR p.brand = ANY(:brands::text[]))
AND (:price_min IS NULL OR p.price >= :price_min)
AND (:price_max IS NULL OR p.price <= :price_max)
AND (:in_stock IS NULL OR p.stock > 0)
ORDER BY p.sort_order
LIMIT 48 OFFSET :offset;
-- Агрегації для лічильників (окремий запит на кожен фільтр)
SELECT brand, COUNT(*) FROM products p
WHERE p.category_id = :cat
-- Всі фільтри КРІМ brand
AND (:price_min IS NULL OR p.price >= :price_min)
GROUP BY brand;
Проблема SQL-підходу: для коректних лічильників потрібен окремий запит агрегації для кожного фільтра, виключаючи цей фільтр з умов. При 10 активних фільтрах — 10 додаткових запитів. На реальному навантаженні це не масштабується.
Faceted search з Elasticsearch
Elasticsearch вирішує завдання за один запит через aggregations:
{
"query": {
"bool": {
"filter": [
{ "term": { "category_id": 14 } },
{ "terms": { "brand": ["Apple", "Samsung"] } },
{ "range": { "price": { "gte": 5000, "lte": 30000 } } }
]
}
},
"aggs": {
"brands": {
"filter": {
"bool": {
"filter": [
{ "term": { "category_id": 14 } },
{ "range": { "price": { "gte": 5000, "lte": 30000 } } }
]
}
},
"aggs": {
"values": { "terms": { "field": "brand", "size": 50 } }
}
},
"price_range": {
"stats": { "field": "price" }
}
}
}
Кожна агрегація (brands, ram, screen_size) використовує фільтр без своєї власної умови — це й є faceted search. Один запит повертає й товари, й всі лічильники для всіх фільтрів.
URL-схема для фільтрів
URL повинен відображати стан фільтрів для шарингу та SEO:
/noutbuki?brand=apple,samsung&ram=16&price_min=50000&price_max=100000&sort=price_asc
При змінені фільтра — pushState або replaceState без перезагрузки сторінки. При прямому входу по URL — інціалізація стану фільтрів з параметрів.
SEO-підхід: популярні комбінації фільтрів (бренд + категорія) оформляються як окремі статичні сторінки з унікальним контентом та canonical. Сторінки з рідкими комбінаціями — <meta name="robots" content="noindex">.
Клієнтська реалізація
Стан фільтрів зберігається в URL (source of truth) та дзеркалюється в React state:
type FilterState = {
brands: string[];
ram: number | null;
priceMin: number | null;
priceMax: number | null;
inStock: boolean;
sort: 'price_asc' | 'price_desc' | 'popularity' | 'rating';
};
function useFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo(() => parseFilters(searchParams), [searchParams]);
const setFilter = (key: keyof FilterState, value: unknown) => {
const next = { ...filters, [key]: value };
setSearchParams(buildParams(next), { replace: true });
};
return { filters, setFilter };
}
При кожній змінені фільтра — debounce 300ms, потім запит до API. Результати оновлюються без перезагрузки сторінки.
Ценовий слайдер
Компонент діапазону цін — окрема задача. Вимоги:
- Два handle (min та max), які не можуть перетнутися
- При введенні з клавіатури — валідація та clamp
- Гістограма розподілу цін за слайдером (показує, де сконцентровані товари)
Гістограма: Elasticsearch aggregation histogram з interval = (max_price - min_price) / 20. Відображується через SVG path або крихітний bar chart.
Готові компоненти: @radix-ui/react-slider, rc-slider, noUiSlider. Radix-варіант переважний при Tailwind-стеку.
Оптимізація продуктивності
Кеш агрегацій: результати підрахунку фасетів не змінюються при кожному запиті. Кешуємо агрегації для категорії з типовим набором фільтрів у Redis на 5–10 хвилин. При обновленні товара інвалідуємо кеш категорії.
Індекси PostgreSQL:
-- Складений індекс для типового запиту
CREATE INDEX ON products (category_id, brand, price)
WHERE status = 'active';
-- GIN-індекс для JSONB-атрибутів
CREATE INDEX ON products USING GIN (attributes);
Lazy loading фасетів: показуємо перші 5–7 значень, кнопка «Показати все» підгружає решту окремим запитом.
Мобільна адаптація
На мобайле фільтри сховані за кнопкою «Фільтри» → відкривається нижній drawer (bottom sheet) на весь екран. Усередині — ті ж компоненти, але з збільшеними touch targets. Кнопка «Застосувати» зафіксована внизу. При застосуванні drawer закривається, список обновляється.
Терміни
- Базова фільтрація (SQL, чекбокси по 3–4 атрибутам, діапазон цін): 1–2 тижні
- Faceted search на Elasticsearch (динамічні лічильники, всі типи фільтрів, URL-синхронізація): 3–4 тижні
- Додавання гістограми цін та кешування агрегацій: +1 тиждень
Вибір між SQL та Elasticsearch визначається розміром каталогу. До 50 000 товарів добре спроектований SQL справиться. Вище — Elasticsearch дає якісну різницю в швидкості та багатстві фасетів.







