# MVP Spec 05 — Catalog & Search + Map

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

- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
  - [Product roadmap и delivery checklist](../product-roadmap.md)
  - [Roadmap](../../project/roadmap.md)
  - [Карта API-маршрутов](../../architecture/api-routes.md)

> 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) | — |

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

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_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).

```prisma
// Существующие модели — используются без изменений:
// 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

```typescript
// ─── 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** | Рендеринг карты и геокодирование |
