AACsearch
SDKРецепты

Рекомендации при отсутствии результатов

Когда `found: 0`, не показывайте пустую страницу. Покажите подборку бестселлеров, подсказки «возможно, вы имели в виду» и поисковую рекомендацию, которая восстанавливает поток пользователя.

Пустая страница «ничего не найдено» — это тупик. Этот рецепт заменяет её тремя взаимодополняющими восстановлениями: исправлением «возможно, вы имели в виду», популярными категориями и списком бестселлеров в качестве запасного варианта.

Что вы создаёте

┌─────────────────────────────────────────┐
│ По запросу "shooes" ничего не найдено    │
│                                          │
│ Возможно, вы имели в виду: shoes         │
│                                          │
│ Популярные категории:                    │
│ [Кроссовки] [Ботинки] [Сандалии]         │
│                                          │
│ ── Бестселлеры ──                        │
│ [...12 карточек товаров...]              │
└─────────────────────────────────────────┘

Компонент

"use client";

import { useEffect, useState } from "react";
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",
});

export function NoResults({ query }: { query: string }) {
  const [didYouMean, setDidYouMean] = useState<string | null>(null);
  const [bestsellers, setBestsellers] = useState<Product[]>([]);
  const [popularCategories, setPopularCategories] = useState<string[]>([]);

  useEffect(() => {
    (async () => {
      // Запускаем все три спасательных запроса параллельно
      const [spell, sellers, cats] = await Promise.all([
        // 1. Проверка орфографии неудачного запроса
        fetch(
          `/api/v1/projects/${process.env.NEXT_PUBLIC_AACSEARCH_ORG_ID}/spell-check`,
          {
            method: "POST",
            headers: {
              Authorization: `Bearer ${process.env.NEXT_PUBLIC_AACSEARCH_SEARCH_KEY}`,
            },
            body: JSON.stringify({ q: query }),
          },
        ).then((r) => r.json()),

        // 2. Топ-12 бестселлеров
        client.search({
          q: "*",
          sortBy: "sales_count:desc",
          perPage: 12,
        }),

        // 3. Топ-5 категорий по количеству товаров (фасет от "*")
        client.search({
          q: "*",
          perPage: 0,
          facetBy: "categories",
          maxFacetValues: 5,
        }),
      ]);

      if (spell.suggestions?.[0] && spell.suggestions[0] !== query) {
        setDidYouMean(spell.suggestions[0]);
      }
      setBestsellers(sellers.hits.map((h) => h.document));
      const catFacet = cats.facetCounts.find((f) => f.field === "categories");
      setPopularCategories(catFacet?.counts.map((c) => c.value) ?? []);
    })();
  }, [query]);

  return (
    <div className="no-results">
      <h2>По запросу &quot;{query}&quot; ничего не найдено</h2>

      {didYouMean && (
        <p className="did-you-mean">
          Возможно, вы имели в виду{" "}
          <a href={`?q=${didYouMean}`}>{didYouMean}</a>?
        </p>
      )}

      {popularCategories.length > 0 && (
        <section>
          <h3>Популярные категории</h3>
          <div className="category-chips">
            {popularCategories.map((cat) => (
              <a key={cat} href={`/c/${cat.toLowerCase()}`} className="chip">
                {cat}
              </a>
            ))}
          </div>
        </section>
      )}

      {bestsellers.length > 0 && (
        <section>
          <h3>Бестселлеры</h3>
          <div className="product-grid">
            {bestsellers.map((p) => (
              <ProductCard key={p.id} product={p} />
            ))}
          </div>
        </section>
      )}
    </div>
  );
}

Отслеживание неудачного запроса

Отправьте событие zero_results, чтобы находить повторяющиеся неудачи и добавлять для них синонимы или контент:

useEffect(() => {
  if (query) {
    fetch("/api/events/track", {
      method: "POST",
      body: JSON.stringify({
        event: "zero_results",
        properties: { query, indexSlug: "products" },
      }),
    });
  }
}, [query]);

Открывайте Поиск → Аналитика → «Топ запросов с нулевым результатом» еженедельно для сортировки.

«Возможно, вы имели в виду»: на сервере, а не на клиенте

Если ваша страница поиска рендерится на сервере, выполните проверку орфографии до рендеринга и передайте подсказку как prop. Пользователь увидит исправленную подсказку при первой отрисовке вместо мигания «ничего не найдено» с последующей отложенной коррекцией.

// app/search/page.tsx
const result = await client.search({ q: searchParams.q, queryBy: "title" });

if (result.found === 0) {
	const spell = await fetch(`...spell-check`, { ... }).then((r) => r.json());
	return <NoResults query={searchParams.q} suggestion={spell.suggestions?.[0]} />;
}

Эвристики для UX «возможно, вы имели в виду»

  • Показывайте подсказку, только если она действительно возвращает результаты — перезапустите поиск с предложенным запросом и проверьте found > 0.
  • Если подсказка отличается на один символ, автоматически перенаправляйте: if (typoDistance === 1) router.replace(?q=${suggestion}).
  • Для нелатинских письменностей (кириллица, арабский, CJK) точность проверки орфографии варьируется. Тестируйте перед включением.

Пустая категория vs пустой поиск

Они требуют разной обработки:

ПоверхностьПричинаЛучший ответ
Результаты поискаПользователь ввёл запрос, который ничего не нашёл«Возможно, вы имели в виду» + популярные категории + бестселлеры
PLP (/c/foo)Категория существует, но пустаПоказать «Скоро будет» или перенаправить в родительскую категорию
404Категория вообще не существуетСтандартный 404

Различайте, проверяя по списку категорий во время сборки:

const categories = await fetch("/api/categories").then((r) => r.json());
const exists = categories.some((c) => c.slug === slug);
if (!exists) notFound();

Связанные страницы

On this page