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. Бизнес-правила и валидации
Таблица валидаций фильтров
| Параметр | Правило | Ошибка пользователю |
|---|---|---|
| query | 2–200 символов | "Введите минимум 2 символа" / "Максимум 200 символов" |
| ageMin / ageMax | 0–99, ageMin ≤ ageMax | "Минимальный возраст не может быть больше максимального" |
| priceFrom / priceTo | ≥ 0, priceFrom ≤ priceTo | "Минимальная цена не может быть больше максимальной" |
| studyFormat | enum: online / offline / hybrid | Невалидное значение игнорируется |
| language | enum: ru / uz_lat / uz_kir / en / kz / tj / any | Невалидное значение игнорируется |
| studytype | enum: group / mini_group / individual | Невалидное значение игнорируется |
| timeslot | enum: morning / afternoon / evening | Невалидное значение игнорируется |
| category | enum из 11 slug-значений | Несуществующий slug игнорируется без ошибки |
| page | integer ≥ 1 | Некорректное значение заменяется на 1 |
| limit | 20 (фиксировано на MVP) | — |
Бизнес-правила
- Только активные айтемы: В каталоге отображаются только записи с
moderation_status = active AND item_isvisible = true. Все остальные статусы (draft, pending, rejected, archived) исключаются на уровне SQL-запроса. - Логика фильтров — AND: Все активные фильтры применяются одновременно через AND. Внутри одного фильтра (например несколько форматов) — OR.
- URL как источник истины: Состояние фильтров полностью определяется URL-параметрами. Страница должна правильно рендериться при прямом переходе по URL с любыми фильтрами.
- Redis-кэш: Ключ кэша —
catalog:{sha256_от_отсортированных_параметров}. TTL = 5 минут. Инвалидация при публикации/снятии айтема (webhook от seller API). - SSR только первая страница: SSR рендерит только первый экран (page=1). Дальнейшая догрузка идёт клиентским infinite scroll без SSR.
- Приватность адресов на карте: Маркер отображается только если
ItemLocation.display_publicly = true. Если false — айтем в каталоге есть (в grid-виде), но маркера на карте нет. - Карта только офлайн/гибрид: На карте не отображаются онлайн-курсы (у них нет физического адреса).
- Bounds Узбекистана: Все координаты проверяются перед отображением маркера. Lat: 37–45.6, Lon: 55.9–73.2. Маркер вне bounds не отображается.
- Debounce на поиск и фильтры: 300мс для text-запроса и каждого фильтра, 500мс для движения карты.
5. Модель данных
Каталог использует существующие сущности. Ниже — атрибуты, используемые в этом модуле.
Item (используемые атрибуты)
| Атрибут | Тип | Описание |
|---|---|---|
| item_id | UUID | PK |
| seller_id | UUID FK | → Seller |
| item_name | string | Название, отображается в карточке |
| item_slug | string | Unique, для URL /item/[slug] |
| item_shortdesc | string | Краткое описание для карточки |
| subject_id | UUID FK | → subject_registry (категория) |
| item_studytype | StudyType | group / mini_group / individual |
| item_studyformat | StudyFormat | online / offline / hybrid |
| item_language | Language | ru / uz_lat / uz_kir / en / kz / tj / any |
| item_timeslot | TimeslotEnum[] | morning / afternoon / evening |
| item_age_from | int? | Минимальный возраст |
| item_age_to | int? | Максимальный возраст |
| item_price_from | Decimal? | Минимальная цена (для фильтра и отображения) |
| item_price_to | Decimal? | Максимальная цена |
| cover_image_url | string? | URL обложки для CarouselCard |
| moderation_status | ItemStatus | Только active попадает в каталог |
| item_isvisible | boolean | Управляемая продавцом видимость |
ItemLocation (используемые атрибуты)
| Атрибут | Тип | Описание |
|---|---|---|
| item_id | UUID FK | → Item |
| city | string | Всегда публичен |
| full_address | string? | Только если display_publicly = true |
| latitude | Decimal? | Только если display_publicly = true |
| longitude | Decimal? | Только если display_publicly = true |
| display_publicly | boolean | Контролирует видимость адреса и маркера на карте |
MapMarkerDto (response object)
| Атрибут | Тип | Описание |
|---|---|---|
| item_id | UUID | PK айтема |
| item_name | string | |
| item_slug | string | Для ссылки в popup |
| cover_image_url | string? | Thumbnail для popup |
| latitude | Decimal | |
| longitude | Decimal | |
| price_from | Decimal? | |
| seller_name | string | Имя/название продавца |
CatalogItemCardDto (response object)
| Атрибут | Тип | Описание |
|---|---|---|
| item_id | UUID | |
| item_slug | string | |
| item_name | string | |
| item_shortdesc | string | |
| cover_image_url | string? | |
| item_studyformat | StudyFormat | |
| item_language | Language | |
| item_age_from | int? | |
| item_age_to | int? | |
| item_price_from | Decimal? | |
| seller_name | string | |
| seller_logo_url | string? | |
| avg_rating | Decimal? | Среднее из reviews (если есть) |
| reviews_count | int | |
| city | string | Из 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 | Исключено из MVP | PostgreSQL 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 |
| Сравнение курсов | Исключено из MVP | Feature comparison — v1.5 |
| Кэш-инвалидация при обновлении айтема | TBD | Нужен webhook/event из seller API → purge Redis key. Механизм не определён |
| Кол-во маркеров на карте при масштабировании | TBD | Лимит 500 маркеров — достаточно для MVP. При росте нужна tile-based загрузка |
| CDN для cover_image_url | TBD | URL 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 | Рендеринг карты и геокодирование |