Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-05-catalog-search-map.md

MVP Spec 05 — Catalog & Search + Map

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев

MVP Spec 05 — Catalog & Search + Map

Паспорт документа

  • Статус документа: working spec
  • Актуально на: 28 марта 2026 года
  • Владелец: backend/platform-команда
  • Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
  • Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
  • Связанные документы:

Version: MVP · Priority: P0 · Phase: B (Demand) Status: Draft v1


1. Контекст и цель

Каталог — главная точка входа байера на платформу. Это страница /, которая является одновременно и лендингом, и поисковым интерфейсом. От качества каталога зависит Discovery — сможет ли потенциальный ученик или родитель найти подходящий курс.

Цель модуля: дать байеру инструмент для поиска и фильтрации образовательных предложений. Поддержать две модели поиска: текстовый поиск + фильтры (сеточный вид) и картографический вид для офлайн-центров.

Ключевые задачи:

  • SSR-рендеринг каталога для SEO (индексирование Google/Yandex)
  • Redis-кэширование результатов (TTL 5 мин)
  • Фильтрация по 9 параметрам через URL-параметры
  • Категориальная навигация (11 категорий)
  • Карта офлайн-центров через OpenStreetMap / Nominatim
  • Infinite scroll каталога

Что не входит в этот модуль:

  • Публичная карточка айтема /item/[slug] → Spec 06
  • Публичный профиль продавца /sellers/[id] → Spec 12
  • Поиск через Elasticsearch → v1.0
  • Сохранение избранного → v1.0
  • Персонализированные рекомендации → v1.5

2. Роли пользователей

РольДействия в этом модуле
ГостьПросматривает каталог, применяет фильтры, ищет по ключевым словам, смотрит карту
Авторизованный BuyerВсё то же что гость + сохранение в избранное (v1.0)
Авторизованный SellerВидит каталог как гость (не открывает особых возможностей)

3. Use Cases


UC-01: Гость открывает каталог, видит дефолтный листинг

Актор: Гость (незарегистрированный пользователь) Предусловие: Нет Триггер: Пользователь переходит на главную страницу /

Полный поток:

[Точка входа]
→ Пользователь открывает браузер, вводит qadam.uz (или следует внешней ссылке)
→ Открывается страница /
→ Страница рендерится на сервере (SSR): Next.js вызывает GET /api/catalog
  с параметрами по умолчанию: page=1, limit=20, сортировка: relevance
→ Redis проверяет ключ catalog:{hash_пустых_фильтров}
  - Если кэш есть (TTL < 5 мин): возвращает кэшированный результат
  - Если кэша нет: запрос в PostgreSQL, результат кэшируется

────────────────────────────────────────────────────────
МАКЕТ СТРАНИЦЫ
────────────────────────────────────────────────────────
→ Хедер с логотипом, SearchBar, кнопками "Войти" / "Разместить курс"
→ CategoryNav — горизонтальный ряд pill-кнопок: Все | Школы | Языки |
  IT | Творчество | Красота и здоровье | Спорт | Вождение |
  Soft Skills | Финансы | Шахматы | Другое
→ Переключатель вида: [Список ≡] [Карта 🗺]  — по умолчанию "Список"
→ FilterSidebar (desktop, слева) — сетка фильтров
→ CourseFeed (справа / центр) — сетка карточек CourseCard
→ Каждая CourseCard содержит:
    - cover_image
    - item_name
    - seller name + logo
    - формат (бейдж: Онлайн / Офлайн / Гибрид)
    - возраст (item_age_from – item_age_to)
    - язык обучения
    - цена от item_price_from
    - рейтинг (avg) + кол-во отзывов (если есть)
→ При наличии дополнительных результатов внизу фида срабатывает infinite scroll sentinel
→ Показывает общее кол-во: "Найдено: {N} курсов"

Состояние загрузки:

→ Пока SSR-запрос выполняется: показываем скелетоны CourseCardSkeleton
  (20 анимированных placeholder-карточек)
→ Скелетоны идентичны по форме реальным карточкам (избегаем layout shift)

UC-01 — Альтернативные потоки и обработка ошибок

1a. Каталог не загрузился (сервер недоступен, SSR-ошибка):

UI-реакция:
→ Страница показывает кастомный error boundary:
  Иллюстрация + "Что-то пошло не так. Мы уже разбираемся."
  Кнопка "Попробовать снова" (обновляет страницу)
→ В логах: полная трассировка ошибки (для Sentry/Pino)
→ Пользователю: НЕ показываем технические детали, только человекочитаемое сообщение

1b. База данных вернула пустой список (нет ни одного активного айтема в системе):

UI-реакция:
→ CourseFeed показывает empty state:
  Иллюстрация + "Курсы скоро появятся. Мы активно набираем партнёров."
→ CategoryNav и фильтры остаются видимыми (не скрываем интерфейс)

UC-02: Пользователь ищет по ключевому слову

Актор: Гость Предусловие: Пользователь находится на странице / Триггер: Пользователь фокусируется на SearchBar и начинает вводить текст

Полный поток:

[Точка входа]
→ Пользователь видит SearchBar в хедере с placeholder "Найти курс, репетитора, школу..."
→ Нажимает на SearchBar — получает фокус
→ Начинает вводить поисковый запрос, например: "английский язык для детей"

────────────────────────────────────────────────────────
ПОИСК
────────────────────────────────────────────────────────
→ Debounce 300мс: после 300мс бездействия отправляется запрос
→ GET /api/catalog?query=английский+язык+для+детей&page=1&limit=20
→ PostgreSQL full-text search по полям:
    item_name (weight A), item_shortdesc (weight B), item_desc (weight C)
→ Результаты отсортированы по релевантности (ts_rank)
→ URL обновляется: /?query=английский+язык+для+детей
→ CourseFeed обновляется без перезагрузки страницы
→ Показывается: "Найдено: {N} результатов по запросу «английский язык для детей»"
→ Кнопка ✕ (очистить поиск) появляется в SearchBar пока есть текст

────────────────────────────────────────────────────────
ПРИМЕНЕНИЕ ПОИСКА
────────────────────────────────────────────────────────
→ Пользователь нажимает Enter или кнопку 🔍 справа от поля
→ URL обновляется (history.pushState)
→ Страница SSR-рендеримая по этому URL (для SEO и шаринга)

Альтернативные потоки:

2a. Запрос не содержит результатов:

→ Переходит к UC-07 (No results state)

2b. Поисковый запрос слишком короткий (< 2 символов):

UI-реакция:
→ Запрос к API НЕ отправляется
→ CourseFeed остаётся с прошлыми результатами
→ Под SearchBar: небольшая подсказка "Введите минимум 2 символа для поиска"
  (появляется через 500мс после фокуса, если поле не пустое и < 2 символов)

2c. Поисковый запрос слишком длинный (> 200 символов):

UI-реакция:
→ Счётчик символов под полем (красный цвет при > 200)
→ Ввод обрезается на 200 символах
→ Под полем: "Максимальная длина запроса — 200 символов"

2d. Ошибка поискового запроса (5xx от /api/catalog):

UI-реакция:
→ Toast (красный): "Ошибка поиска. Попробуйте ещё раз."
→ CourseFeed показывает последний успешный результат
→ SearchBar остаётся активным с введённым текстом

UC-03: Пользователь применяет фильтры

Актор: Гость Предусловие: Пользователь находится на странице / (любой каталог) Триггер: Пользователь взаимодействует с FilterSidebar (desktop) или нажимает "Фильтры" (mobile)

Полный поток:

[Точка входа]
→ Desktop: FilterSidebar видна слева
→ Mobile: пользователь нажимает кнопку "Фильтры" (с иконкой 🔧) в верхней части фида
  → открывается FilterDrawer (bottom sheet / модал снизу)

────────────────────────────────────────────────────────
ДОСТУПНЫЕ ФИЛЬТРЫ
────────────────────────────────────────────────────────
Формат обучения (checkbox):
    ☐ Онлайн  ☐ Офлайн  ☐ Гибрид

Возраст (range slider):
    от [__] до [__] лет
    Input-поля для ручного ввода

Стоимость (range slider, UZS):
    от [______] до [______]
    Вводятся вручную или перетаскивается ползунок

Язык обучения (checkbox):
    ☐ Русский  ☐ Узбекский  ☐ Английский  ☐ Другой

Тип занятия (checkbox):
    ☐ Группа  ☐ Мини-группа  ☐ Индивидуально (1:1)

Время суток (checkbox):
    ☐ Утро (6:00–12:00)
    ☐ День (12:00–17:00)
    ☐ Вечер (17:00–22:00)

Город (autocomplete, только если вид = список):
    Поле с текстом, предлагает города Узбекистана

────────────────────────────────────────────────────────
ПРИМЕНЕНИЕ ФИЛЬТРОВ
────────────────────────────────────────────────────────
→ Desktop: каждый чекбокс / ползунок применяется немедленно (debounce 300мс)
→ Mobile (FilterDrawer): изменения применяются при нажатии "Показать результаты"
→ URL обновляется при каждом изменении:
    /?studyFormat=online,offline&ageMin=7&ageMax=14&priceFrom=100000
→ Фид перезагружает результаты
→ Активные фильтры отображаются как "chips/теги" над фидом:
    [Онлайн ✕] [7–14 лет ✕] [от 100 000 сум ✕]   [Сбросить всё]
→ Счётчик активных фильтров на кнопке "Фильтры" (mobile): "Фильтры (3)"

Альтернативные потоки:

3a. Пользователь вводит некорректный диапазон возраста (from > to):

UI-реакция:
→ Поля возраста: красная обводка + ⚠
→ Под полями: "Минимальный возраст не может быть больше максимального"
→ Запрос к API не отправляется до исправления

3b. Пользователь вводит некорректный диапазон цены (from > to):

UI-реакция:
→ Поля цены: красная обводка + ⚠
→ Под полями: "Минимальная цена не может быть больше максимальной"
→ Запрос к API не отправляется до исправления

3c. Применены фильтры, дающие 0 результатов:

→ Переходит к UC-07 (No results state)

3d. Mobile FilterDrawer — пользователь нажал "Показать результаты", фильтры дают 0 результатов:

UI-реакция:
→ Кнопка "Показать результаты" показывает: "Нет результатов — изменить фильтры"
→ Drawer НЕ закрывается — пользователь остаётся в нём для корректировки
→ Внутри Drawer: подсказка "По текущим фильтрам ничего не найдено. Уберите часть фильтров."

3e. Фильтр по городу — введён город не из Узбекистана:

UI-реакция:
→ Поле города: красная обводка + ⚠
→ Под полем: "Выберите город из Узбекистана"
→ Autocomplete не предлагает города вне Узбекистана (фильтр countrycodes=uz)

UC-04: Пользователь переходит по категории

Актор: Гость Предусловие: Пользователь находится на странице / Триггер: Пользователь нажимает одну из pill-кнопок в CategoryNav

Полный поток:

[Точка входа]
→ Пользователь видит CategoryNav под хедером:
    [Все] [Школы] [Языки] [IT] [Творчество] [Красота и здоровье]
    [Спорт] [Вождение] [Soft Skills] [Финансы] [Шахматы] [Другое]
→ По умолчанию активна pill "Все" (выделена цветом)

→ Пользователь нажимает, например, "IT"
→ Pill "IT" становится активной, "Все" — неактивной
→ URL обновляется: /?category=it
→ GET /api/catalog?category=it&page=1&limit=20
→ CourseFeed показывает только айтемы из категории IT
→ Заголовок над фидом: "IT-курсы · Найдено: {N}"
→ FilterSidebar остаётся активной — можно комбинировать с фильтрами
    /?category=it&studyFormat=online&ageMin=14

→ Пользователь нажимает "Все" — категориальный фильтр сбрасывается
→ URL: / (без параметра category)

Альтернативные потоки:

4a. URL с несуществующей категорией (?category=xyz):

Поведение:
→ Сервер: параметр category игнорируется (не бросает ошибку)
→ Каталог показывает все айтемы без категориального фильтра
→ Ни одна pill в CategoryNav не подсвечена (или подсвечена "Все")
→ Не показываем 404 — это деградация, а не ошибка

UC-05: Пользователь смотрит офлайн-центры на карте, кликает маркер

Актор: Гость Предусловие: Пользователь находится на странице / (вид "Список") Триггер: Пользователь нажимает переключатель [Карта 🗺]

Полный поток:

[Точка входа]
→ Пользователь видит переключатель вида: [Список ≡] [Карта 🗺]
→ Нажимает [Карта 🗺]

────────────────────────────────────────────────────────
ИНИЦИАЛИЗАЦИЯ КАРТЫ
────────────────────────────────────────────────────────
→ Компонент QadamMap загружается (lazy, с ssr:false чтобы избежать SSR-краша)
→ Пока карта грузится: spinner + "Загружаем карту..."
→ GET /api/catalog/map-markers?city=tashkent (или текущий выбранный город)
→ Карта центрируется на Ташкенте (lat: 41.2995, lon: 69.2401), zoom: 12
→ На карте отображаются маркеры кластерами для точек с близкими координатами
→ Фид справа (desktop) или снизу (mobile) показывает список карточек айтемов,
  соответствующих маркерам в текущем viewport карты

────────────────────────────────────────────────────────
ТОЛЬКО ОФЛАЙН/ГИБРИД АЙТЕМЫ
────────────────────────────────────────────────────────
→ На карте отображаются ТОЛЬКО айтемы с:
    item_studyformat IN (offline, hybrid)
    ItemLocation.display_publicly = true
    moderation_status = active AND item_isvisible = true
→ Если у айтема display_publicly = false: маркер НЕ отображается на карте
  (приватные адреса скрыты — только город доступен)

────────────────────────────────────────────────────────
ВЗАИМОДЕЙСТВИЕ С МАРКЕРОМ
────────────────────────────────────────────────────────
→ Пользователь кликает на маркер
→ Появляется popup с мини-карточкой айтема:
    - cover_image (thumbnail 80×80)
    - item_name
    - seller name
    - format badge
    - цена от {price_from} сум
    - кнопка "Подробнее →"
→ Пользователь нажимает "Подробнее →"
→ Переходит на /item/[slug]

────────────────────────────────────────────────────────
КЛАСТЕРЫ
────────────────────────────────────────────────────────
→ Если в одной точке или близко несколько маркеров — показывается кластер
  с числом (например [5])
→ Клик на кластер: карта зумируется на эту область
→ При достаточном zoom кластер раскрывается в отдельные маркеры

────────────────────────────────────────────────────────
СИНХРОНИЗАЦИЯ КАРТЫ И ФИДА
────────────────────────────────────────────────────────
→ При движении карты (pan/zoom): фид справа/снизу обновляется
  (показывает только те айтемы, чьи маркеры видны в текущем viewport)
→ Debounce 500мс на обновление при движении карты
→ При клике на карточку в боковом фиде: карта центрируется на соответствующем маркере,
  маркер подсвечивается

Альтернативные потоки:

5a. Ни один маркер не попадает в выбранные фильтры:

UI-реакция:
→ Карта пустая (без маркеров)
→ В боковом фиде: "В этом районе нет курсов по выбранным параметрам."
→ Кнопка "Сбросить фильтры" и "Расширить зону поиска" (увеличивает viewport)

5b. Geolocation — пользователь разрешил определение геолокации:

→ Кнопка "Рядом со мной" (иконка 📍) в углу карты
→ При нажатии: запрашивается разрешение браузера
→ Если разрешено: карта центрируется на текущей позиции пользователя
→ Если отклонено: Toast "Разрешите доступ к геолокации в настройках браузера"
→ Координаты пользователя НЕ отправляются на сервер

5c. Карта не загрузилась (Nominatim/OSM недоступен):

UI-реакция:
→ Вместо карты: серый блок-заглушка
→ Текст: "Карта временно недоступна. Используйте режим списка для поиска."
→ Кнопка "Переключить на список" → возвращает в grid-вид

5d. Маркер с координатами [0, 0] (баг в данных):

Поведение:
→ Координаты [0, 0] — вне bounds Узбекистана → маркер НЕ отображается
→ Bounds-проверка: lat 37–45.6, lon 55.9–73.2
→ Логируется как ошибка данных (для мониторинга)

UC-06: Пользователь сбрасывает все фильтры

Актор: Гость Предусловие: Пользователь применил один или несколько фильтров Триггер: Пользователь нажимает "Сбросить всё"

Полный поток:

[Точка входа]
→ Пользователь видит активные filter-chips над фидом:
    [Онлайн ✕] [7–14 лет ✕] [IT ✕]    [Сбросить всё]

Вариант A — "Сбросить всё":
→ Нажимает ссылку/кнопку "Сбросить всё"
→ URL очищается до /
→ Все чекбоксы FilterSidebar сбрасываются
→ Все slider-ы возвращаются к default-диапазонам
→ CategoryNav возвращается к "Все"
→ SearchBar очищается
→ CourseFeed показывает дефолтный каталог
→ Chips исчезают

Вариант B — Закрытие отдельного chip [Онлайн ✕]:
→ Нажимает ✕ на конкретном chip
→ Только этот фильтр снимается
→ URL обновляется (убирается только этот параметр)
→ Фид пересчитывает результаты

Вариант C — Сброс конкретного фильтра в FilterSidebar:
→ Нажимает кнопку "Сбросить" рядом с группой фильтра (например "Сбросить возраст")
→ Только эта группа фильтров сбрасывается

UC-07: Состояние "нет результатов"

Актор: Гость Предусловие: Применены фильтры или поисковый запрос, вернувший 0 айтемов Триггер: GET /api/catalog вернул { items: [], total: 0 }

Полный поток:

[Точка входа]
→ Пользователь применил фильтры или ввёл запрос
→ Сервер вернул 0 результатов

────────────────────────────────────────────────────────
NO RESULTS STATE
────────────────────────────────────────────────────────
→ Вместо CourseFeed отображается:
    [Иллюстрация — пустой поиск]
    "По запросу «{query}» ничего не найдено"
    (или "По выбранным фильтрам ничего не найдено" — если нет text query)

→ Рекомендации:
    • "Попробуйте изменить фильтры"
    • [Кнопка: Сбросить все фильтры]
    • "Или посмотрите похожие категории:"
      [Ссылки на соседние категории CategoryNav]

→ FilterSidebar и CategoryNav остаются доступными — пользователь может
  изменить параметры без перезагрузки страницы

UC-08: Infinite scroll каталога

Актор: Гость Предусловие: Каталог загружен, total > 20 Триггер: Пользователь скроллит вниз до нижнего sentinel-элемента списка

Полный поток:

[Точка входа]
→ Пользователь видит первую страницу карточек
→ Внизу сетки находится sentinel-элемент
→ Пользователь доскролливает до него
→ Список переходит в состояние loading следующей страницы
→ GET /api/catalog?...&page=2&limit=20
→ 20 новых карточек добавляются в конец сетки (append, не замена)
→ Если все результаты загружены (загружено = total):
    sentinel перестаёт инициировать новые запросы
    при желании показывается финальное состояние "Все {total} курсов загружены"

────────────────────────────────────────────────────────
URL НЕ ИЗМЕНЯЕТСЯ при пагинации
────────────────────────────────────────────────────────
→ URL остаётся /?...параметры... без &page=2
→ При обновлении страницы пользователь вернётся к первым 20 результатам
→ Восстановление позиции скролла при возврате остаётся отдельной задачей следующего этапа

Альтернативные потоки:

8a. Ошибка при загрузке следующей страницы:

UI-реакция:
→ Внизу ленты появляется состояние ошибки / retry для следующей страницы
→ Уже загруженные карточки остаются на месте
→ Данные не теряются

4. Бизнес-правила и валидации

Таблица валидаций фильтров

ПараметрПравилоОшибка пользователю
query2–200 символов"Введите минимум 2 символа" / "Максимум 200 символов"
ageMin / ageMax0–99, ageMin ≤ ageMax"Минимальный возраст не может быть больше максимального"
priceFrom / priceTo≥ 0, priceFrom ≤ priceTo"Минимальная цена не может быть больше максимальной"
studyFormatenum: online / offline / hybridНевалидное значение игнорируется
languageenum: ru / uz_lat / uz_kir / en / kz / tj / anyНевалидное значение игнорируется
studytypeenum: group / mini_group / individualНевалидное значение игнорируется
timeslotenum: morning / afternoon / eveningНевалидное значение игнорируется
categoryenum из 11 slug-значенийНесуществующий slug игнорируется без ошибки
pageinteger ≥ 1Некорректное значение заменяется на 1
limit20 (фиксировано на MVP)

Бизнес-правила

  1. Только активные айтемы: В каталоге отображаются только записи с moderation_status = active AND item_isvisible = true. Все остальные статусы (draft, pending, rejected, archived) исключаются на уровне SQL-запроса.
  2. Логика фильтров — AND: Все активные фильтры применяются одновременно через AND. Внутри одного фильтра (например несколько форматов) — OR.
  3. URL как источник истины: Состояние фильтров полностью определяется URL-параметрами. Страница должна правильно рендериться при прямом переходе по URL с любыми фильтрами.
  4. Redis-кэш: Ключ кэша — catalog:{sha256_от_отсортированных_параметров}. TTL = 5 минут. Инвалидация при публикации/снятии айтема (webhook от seller API).
  5. SSR только первая страница: SSR рендерит только первый экран (page=1). Дальнейшая догрузка идёт клиентским infinite scroll без SSR.
  6. Приватность адресов на карте: Маркер отображается только если ItemLocation.display_publicly = true. Если false — айтем в каталоге есть (в grid-виде), но маркера на карте нет.
  7. Карта только офлайн/гибрид: На карте не отображаются онлайн-курсы (у них нет физического адреса).
  8. Bounds Узбекистана: Все координаты проверяются перед отображением маркера. Lat: 37–45.6, Lon: 55.9–73.2. Маркер вне bounds не отображается.
  9. Debounce на поиск и фильтры: 300мс для text-запроса и каждого фильтра, 500мс для движения карты.

5. Модель данных

Каталог использует существующие сущности. Ниже — атрибуты, используемые в этом модуле.

Item (используемые атрибуты)

АтрибутТипОписание
item_idUUIDPK
seller_idUUID FK→ Seller
item_namestringНазвание, отображается в карточке
item_slugstringUnique, для URL /item/[slug]
item_shortdescstringКраткое описание для карточки
subject_idUUID FK→ subject_registry (категория)
item_studytypeStudyTypegroup / mini_group / individual
item_studyformatStudyFormatonline / offline / hybrid
item_languageLanguageru / uz_lat / uz_kir / en / kz / tj / any
item_timeslotTimeslotEnum[]morning / afternoon / evening
item_age_fromint?Минимальный возраст
item_age_toint?Максимальный возраст
item_price_fromDecimal?Минимальная цена (для фильтра и отображения)
item_price_toDecimal?Максимальная цена
cover_image_urlstring?URL обложки для CarouselCard
moderation_statusItemStatusТолько active попадает в каталог
item_isvisiblebooleanУправляемая продавцом видимость

ItemLocation (используемые атрибуты)

АтрибутТипОписание
item_idUUID FK→ Item
citystringВсегда публичен
full_addressstring?Только если display_publicly = true
latitudeDecimal?Только если display_publicly = true
longitudeDecimal?Только если display_publicly = true
display_publiclybooleanКонтролирует видимость адреса и маркера на карте

MapMarkerDto (response object)

АтрибутТипОписание
item_idUUIDPK айтема
item_namestring
item_slugstringДля ссылки в popup
cover_image_urlstring?Thumbnail для popup
latitudeDecimal
longitudeDecimal
price_fromDecimal?
seller_namestringИмя/название продавца

CatalogItemCardDto (response object)

АтрибутТипОписание
item_idUUID
item_slugstring
item_namestring
item_shortdescstring
cover_image_urlstring?
item_studyformatStudyFormat
item_languageLanguage
item_age_fromint?
item_age_toint?
item_price_fromDecimal?
seller_namestring
seller_logo_urlstring?
avg_ratingDecimal?Среднее из reviews (если есть)
reviews_countint
citystringИз ItemLocation

6. Технические контракты

6.1 Prisma Schema

Каталог читает существующие модели Item и ItemLocation. Новых моделей не создаётся. Для расчёта avg_rating и reviews_count — подзапрос к модели Review (Spec 08).

// Существующие модели — используются без изменений:
// Item, ItemLocation, ItemPriceVariant, Seller,
// SchoolProfile, OnlineSchoolProfile, IndividualContributorProfile

// Вспомогательный индекс для full-text search (добавляется миграцией):
// CREATE INDEX item_fts_idx ON "Item"
//   USING GIN (to_tsvector('russian', item_name || ' ' || item_shortdesc || ' ' || item_desc));

6.2 TypeScript DTO

// ─── Query параметры каталога ──────────────────────────────────────────────

export class CatalogQueryDto {
  @IsOptional() @IsString() @MinLength(2) @MaxLength(200)
  query?: string

  @IsOptional() @IsIn(['online', 'offline', 'hybrid'])
  studyFormat?: string  // можно передать несколько через запятую

  @IsOptional() @IsIn(['ru', 'uz_lat', 'uz_kir', 'en', 'kz', 'tj', 'any'])
  language?: string

  @IsOptional() @IsIn(['group', 'mini_group', 'individual'])
  studytype?: string

  @IsOptional() @IsIn(['morning', 'afternoon', 'evening'])
  timeslot?: string

  @IsOptional() @IsInt() @Min(0) @Max(99)
  ageMin?: number

  @IsOptional() @IsInt() @Min(0) @Max(99)
  ageMax?: number

  @IsOptional() @IsDecimal()
  priceFrom?: number

  @IsOptional() @IsDecimal()
  priceTo?: number

  @IsOptional() @IsString()
  category?: string  // slug из subject_registry top-level

  @IsOptional() @IsString()
  city?: string

  @IsOptional() @IsInt() @Min(1)
  page?: number  // default: 1

  @IsOptional() @IsInt() @Min(1) @Max(100)
  limit?: number  // default: 20, max: 100
}

// ─── Response ─────────────────────────────────────────────────────────────

export interface CatalogResponse {
  items: CatalogItemCardDto[]
  total: number
  page: number
  limit: number
  has_more: boolean
}

export interface CatalogItemCardDto {
  item_id: string
  item_slug: string
  item_name: string
  item_shortdesc: string
  cover_image_url: string | null
  item_studyformat: 'online' | 'offline' | 'hybrid'
  item_language: string
  item_age_from: number | null
  item_age_to: number | null
  item_price_from: number | null
  seller_name: string
  seller_logo_url: string | null
  avg_rating: number | null
  reviews_count: number
  city: string | null
}

export interface MapMarkersResponse {
  markers: MapMarkerDto[]
}

export interface MapMarkerDto {
  item_id: string
  item_name: string
  item_slug: string
  cover_image_url: string | null
  latitude: number
  longitude: number
  price_from: number | null
  seller_name: string
}

6.3 API Endpoints

────────────────────────────────────────────────────────────────
ПУБЛИЧНЫЙ API: КАТАЛОГ
────────────────────────────────────────────────────────────────

GET /api/catalog
Auth: Public
Query: CatalogQueryDto
→ 200: CatalogResponse
→ 400: { error: 'INVALID_FILTER', message: string }  // если query < 2 символов и указан

Поведение сервера:
  WHERE moderation_status = 'active' AND item_isvisible = true
  Full-text search: ts_rank(to_tsvector(...), plainto_tsquery(?query))
  Filters: AND-логика между группами, OR внутри мультивыбора
  Caching: Redis key = catalog:{sha256(sorted_params)}, TTL = 300s
  Fallback: если Redis недоступен — запрос идёт напрямую в PostgreSQL

────────────────────────────────────────────────────────────────

GET /api/catalog/map-markers
Auth: Public
Query: { city?: string, studyFormat?: string, category?: string, ...остальные фильтры }
→ 200: MapMarkersResponse
→ Возвращает только айтемы с:
    item_studyformat IN ('offline', 'hybrid')
    ItemLocation.display_publicly = true
    AND координаты в bounds Узбекистана (lat 37–45.6, lon 55.9–73.2)
    AND moderation_status = 'active' AND item_isvisible = true
→ Без пагинации (до 500 маркеров на запрос, MVP)
→ Caching: Redis, TTL = 300s

────────────────────────────────────────────────────────────────

GET /api/catalog/categories
Auth: Public
→ 200: { categories: { slug: string, name: string, count: number }[] }
→ Список 11 топ-уровневых категорий с количеством активных айтемов
→ Caching: Redis, TTL = 300s

7. Edge Cases и обработка ошибок

СценарийПоведение
URL с параметром ?page=999 (нет такой страницы)Возвращает { items: [], total: N, has_more: false } без ошибки
Redis недоступенFallback на прямой PostgreSQL запрос, без кэша
Айтем был снят с публикации пока пользователь смотрит страницуПри следующем запросе каталога / очередной догрузке infinite scroll он исчезнет из выдачи
ItemLocation без координат (null latitude/longitude)Айтем в grid-каталоге есть, маркера на карте нет
Два айтема с одинаковым slugНевозможно (unique constraint). Обрабатывается на этапе создания айтема (Spec 02)
MapMarkers запрос — координаты [0, 0] в данныхИсключаются bounds-проверкой: lat 37–45.6, lon 55.9–73.2
Swapped lat/lon в данных (lon < lat)Отображается некорректный маркер. Митигация: валидация при сохранении в Spec 02
Пустой query="" (явно передан пустой параметр)Игнорируется (как будто query не передан), возвращается дефолтный каталог
Номинатим rate limit (автокомплит города)Debounce 300мс + countrycodes=uz. При 429: автокомплит временно отключается, поле работает как обычный input
SSR crash на карте (window/document not defined)Map загружается только с dynamic(..., { ssr: false }). SSR-краш исключён
Пользователь быстро меняет фильтры (race condition ответов)Frontend: AbortController отменяет предыдущий fetch при новом запросе

8. TBD / Сознательно опущено

ТемаСтатусПримечание
ElasticsearchИсключено из MVPPostgreSQL full-text search достаточен до ~100k айтемов. Elastic — v1.0
Персонализированные рекомендацииИсключено из MVPНет user-истории и ML-пайплайна. Аналитика — v1.5
Сохранение в избранноеИсключено из MVPТребует buyer-аккаунта и UI закладок — v1.0
Infinite scroll каталогаЧастично реализованоВ production уже используется useInfiniteQuery + IntersectionObserver. Восстановление позиции скролла и web e2e-покрытие остаются задачами следующего этапа
Сортировка результатов (по цене, рейтингу, новизне)Исключено из MVPТолько по релевантности. Сортировка — v1.0
Геопоиск "в радиусе N км"Исключено из MVPФильтр по городу достаточен для MVP. Radius search — v1.0
Сравнение курсовИсключено из MVPFeature comparison — v1.5
Кэш-инвалидация при обновлении айтемаTBDНужен webhook/event из seller API → purge Redis key. Механизм не определён
Кол-во маркеров на карте при масштабированииTBDЛимит 500 маркеров — достаточно для MVP. При росте нужна tile-based загрузка
CDN для cover_image_urlTBDURL CDN не определён
Языковая версия каталога (uz/ru)TBDИнтернационализация i18n — требует отдельной проработки

9. Зависимости

МодульСвязь
Spec 02 (Item Management)Item.moderation_status, item_isvisible — контролируют попадание в каталог
Spec 04 (Admin / Moderation)Только модерированные (active) айтемы видны в каталоге
Spec 06 (Item Detail Card)Клик на CourseCard → /item/[slug]
Spec 08 (Reviews)avg_rating и reviews_count агрегируются из таблицы Review
Spec 12 (Public Seller Profile)Клик на seller_name → /sellers/[id]
Spec 01 (Seller Profile)ItemLocation.display_publicly — берётся из настроек продавца при создании айтема
Инфраструктура: RedisКэш-слой для catalog: и map-markers: ключей
Инфраструктура: OpenStreetMap/NominatimРендеринг карты и геокодирование