Фасетный поиск
Боковая панель с множественным выбором фильтров (бренд, категория, диапазон цен) на основе фасетных подсчётов AACsearch. Фильтры комбинируются через И; подсчёты обновляются реактивно.
Интерфейс фасетного поиска позволяет пользователям сужать результаты, нажимая на чекбоксы, ползунки и чипсы. AACsearch возвращает подсчёты по каждому значению фасета вместе с результатами — вы привязываете их к UI.
Что вы создаёте
Страницу поиска с:
- Боковой панелью с чекбоксами брендов, чекбоксами категорий и ползунком диапазона цен.
- Сеткой результатов, которая обновляется при переключении фильтров.
- Фасетными подсчётами, которые отражают «сколько результатов я бы увидел, если бы добавил этот фильтр».
- Состоянием в URL, чтобы фильтры можно было расшарить и они переживали перезагрузку страницы.
Требования к схеме индекса
Чтобы поле было доступно для фасетного поиска, пометьте его facet: true в схеме индекса:
await admin.createIndex({
slug: "products",
displayName: "Products",
fields: [
{ name: "title", type: "string" },
{ name: "brand", type: "string", facet: true },
{ name: "categories", type: "string[]", facet: true },
{ name: "price", type: "float", facet: true },
{ name: "in_stock", type: "bool", facet: true },
],
defaultSortingField: "price",
});Серверная часть: построение поискового запроса из параметров URL
// app/search/page.tsx — Server Component
import { SearchClient } from "@aacsearch/client";
const client = new SearchClient({
baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
apiKey: process.env.NEXT_PUBLIC_AACSEARCH_SEARCH_KEY!,
indexSlug: "products",
});
type SearchParams = {
q?: string;
brand?: string | string[];
category?: string | string[];
priceMin?: string;
priceMax?: string;
page?: string;
};
function buildFilter(params: SearchParams): string {
const clauses: string[] = ["in_stock:=true"];
const brands = Array.isArray(params.brand)
? params.brand
: params.brand
? [params.brand]
: [];
if (brands.length) clauses.push(`brand:[${brands.join(",")}]`);
const cats = Array.isArray(params.category)
? params.category
: params.category
? [params.category]
: [];
if (cats.length) clauses.push(`categories:[${cats.join(",")}]`);
if (params.priceMin) clauses.push(`price:>=${Number(params.priceMin)}`);
if (params.priceMax) clauses.push(`price:<=${Number(params.priceMax)}`);
return clauses.join(" && ");
}
export default async function SearchPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const result = await client.search({
q: searchParams.q ?? "*",
queryBy: "title,brand",
filterBy: buildFilter(searchParams),
facetBy: "brand,categories,in_stock",
page: Number(searchParams.page ?? 1),
perPage: 24,
});
return (
<div className="search-page">
<FacetSidebar facets={result.facetCounts} active={searchParams} />
<ResultGrid hits={result.hits} found={result.found} />
</div>
);
}Компонент боковой панели фасетов
"use client";
import { useRouter, useSearchParams } from "next/navigation";
type FacetCounts = Array<{
field: string;
counts: Array<{ value: string; count: number }>;
}>;
export function FacetSidebar({ facets }: { facets: FacetCounts }) {
const router = useRouter();
const params = useSearchParams();
function toggle(field: string, value: string) {
const next = new URLSearchParams(params);
const current = next.getAll(field);
if (current.includes(value)) {
next.delete(field);
current.filter((v) => v !== value).forEach((v) => next.append(field, v));
} else {
next.append(field, value);
}
next.delete("page"); // сброс пагинации при изменении фильтра
router.push(`?${next}`);
}
const brand = facets.find((f) => f.field === "brand");
const cats = facets.find((f) => f.field === "categories");
return (
<aside className="facet-sidebar">
<section>
<h3>Brand</h3>
{brand?.counts.map((c) => (
<label key={c.value}>
<input
type="checkbox"
checked={params.getAll("brand").includes(c.value)}
onChange={() => toggle("brand", c.value)}
/>
{c.value} <span className="count">({c.count})</span>
</label>
))}
</section>
<section>
<h3>Category</h3>
{cats?.counts.map((c) => (
<label key={c.value}>
<input
type="checkbox"
checked={params.getAll("category").includes(c.value)}
onChange={() => toggle("category", c.value)}
/>
{c.value} <span className="count">({c.count})</span>
</label>
))}
</section>
</aside>
);
}Краткий обзор синтаксиса фильтров
| Потребность | Выражение фильтра |
|---|---|
| Равенство одному значению | brand:=Nike |
| ИЛИ по нескольким значениям | brand:[Nike,Adidas] (использует инвертированный индекс — быстро) |
| Числовой диапазон | price:>=10 && price:<=50 |
| Булево значение | in_stock:=true |
| Отрицание | categories:!=Discontinued |
| Комбинирование | Используйте && для И, || для ИЛИ (только одиночные литеральные значения — см. фильтрация-сортировка-пагинация) |
Реактивные подсчёты
Когда пользователь добавляет фильтр brand, фасетный подсчёт для самого brand не уменьшается (иначе фильтр скрыл бы собственные опции). Подсчёты для categories сужаются, потому что они отражают набор результатов после применения фильтра.
Это поведение AACsearch по умолчанию. Чтобы переопределить (например, «показывать все опции независимо от фильтра»), используйте multi-search с одним запросом для результатов и вторым нефильтрованным запросом для фасетных подсчё тов.
Паттерн состояния в URL
Параметры URL — единственный источник истины. Серверный компонент читает их, FacetSidebar записывает их. Никакого useState — всё управляется через URL. Это:
- Переживает перезагрузку.
- Делает отфильтрованные представления доступными для расшаривания.
- Корректно работает с кнопками браузера вперёд/назад.
- Устраняет рассинхронизацию клиента и сервера.
Производительность: ограничение фасетных подсчётов
По умолчанию AACsearch возвращает до 100 уникальных значений на одно фасетное поле. Для полей с очень высокой кардинальностью (например, тег tag товара с 50 000 уникальных значений), ограничьте явно:
client.search({
q: "*",
facetBy: "brand,categories",
maxFacetValues: 30, // топ-30 по количеству, остальные агрегируются как "Other"
});При более чем ~10 фасетных полях на запрос задержка заметно возрастает. Если
вам нужно много фильтров, отрисовывайте основные поля при первой отрисовке и
лениво подгружайте остальные через multi-search, когда пользователь их
раскрывает.
Связанные страницы
- Фильтрация, сортировка, пагинация
- Страница списка товаров
- Медленный поиск — если фасеты работают медленно
Автодополнение
Поиск с задержкой по мере ввода и выпадающим списком подсказок. Использует `useDeferredValue` для React-идиоматичного debounce и `multi-search` для получения товаров и категорий в одном запросе.
Страница списка товаров
Серверно-рендеренная PLP электронной коммерции с фильтрами, сортировкой, пагинацией и SEO-дружественными URL. Объединяет паттерны фасетного поиска и пагинации в завершённую страницу.