# MVP Spec 06 — Item Detail Card (Public Page)

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

- Статус документа: 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. Контекст и цель

Страница айтема `/item/[slug]` — главная конверсионная страница платформы. Это публичная карточка курса, секции или услуги репетитора. Именно здесь байер принимает решение о том, чтобы оставить заявку (лид).

**Цель модуля:** показать потенциальному ученику или родителю полную информацию о курсе — все trust-сигналы — чтобы снизить неопределённость и стимулировать конверсию в лид. Страница рендерится на сервере (SSR) для индексирования поисковыми системами.

**Ключевые задачи:**
- Полное описание, параметры, результаты обучения
- Матрица цен и спецпредложения
- Преподаватели (performer-профили)
- Фото- и видеогалерея
- Карта (для офлайн/гибрид с публичным адресом)
- Блок отзывов с рейтингом
- Блок "Похожие курсы"
- Сайдбар с CTA "Записаться" → открывает LeadModal (Spec 07)
- SSR + OpenGraph + JSON-LD (Course schema.org)

**Что не входит в этот модуль:**
- Отправка лида (форма LeadModal) → Spec 07
- Каталог и поиск → Spec 05
- Публичная страница продавца `/sellers/[id]` → Spec 12
- Управление отзывами → Spec 08
- Создание и управление айтемом (seller-side) → Spec 02

---

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

| Роль | Действия в этом модуле |
|------|----------------------|
| **Гость** | Просматривает страницу айтема, кликает на CTA, открывает LeadModal |
| **Авторизованный Buyer** | То же + возможность оставить отзыв (Spec 08) |
| **Авторизованный Seller** | Видит страницу как гость (не получает admin-доступ к своему айтему через этот маршрут) |

---

## 3. Use Cases

---

### UC-01: Гость открывает страницу айтема, видит всю информацию

**Актор:** Гость
**Предусловие:** Айтем существует, `moderation_status = active`, `item_isvisible = true`
**Триггер:** Пользователь нажимает на CourseCard в каталоге или переходит по прямой ссылке

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

```
[Точка входа]
→ Пользователь находится в каталоге (/), нажимает на карточку CourseCard
→ Или вводит напрямую в браузере: qadam.uz/item/angliiskiy-dlya-detey-7-12-let
→ Браузер выполняет переход на /item/[slug]

────────────────────────────────────────────────────────
SSR РЕНДЕРИНГ
────────────────────────────────────────────────────────
→ Next.js вызывает getServerSideProps
→ GET /api/items/:slug → возвращает полные данные айтема
→ Если айтем найден и active: рендерит страницу с полными данными
→ В <head>:
    <title>{item_name} — {seller_name} | Qadam</title>
    <meta name="description" content="{item_shortdesc}">
    <meta property="og:title" content="{item_name}">
    <meta property="og:description" content="{item_shortdesc}">
    <meta property="og:image" content="{cover_image_url}">
    <meta property="og:url" content="https://qadam.uz/item/{item_slug}">
    <script type="application/ld+json">
      Course schema.org JSON-LD (см. раздел 6)
    </script>

────────────────────────────────────────────────────────
СТРУКТУРА СТРАНИЦЫ (layout)
────────────────────────────────────────────────────────
→ Хедер (стандартный)
→ Breadcrumbs: Главная → {Категория} → {item_name}
→ Основной контент (2 колонки на desktop, 1 колонка на mobile):

[ЛЕВАЯ / ОСНОВНАЯ КОЛОНКА]
────────────────────────────────────────────────────────
Блок 1 — Шапка айтема:
    → {item_name} — крупный H1
    → Бейджи параметров: [Онлайн] [Русский] [7–14 лет] [Вечер]
    → Рейтинг: ★★★★☆ 4.3  · {N} отзывов (ссылка скроллит к блоку отзывов)
    → Имя продавца (ссылка → /sellers/[seller_id])

Блок 2 — Галерея:
    → cover_image отображается как главное фото
    → Thumbnail-ряд: дополнительные фото из ItemMedia (photo)
    → Если есть video_link: кнопка ▶ Смотреть видео (открывает embed YouTube/Vimeo
      в лайтбоксе или inline-блоке)
    → Клик на thumbnail: главное фото меняется (слайдер)

Блок 3 — Описание:
    → H2: "О курсе"
    → {item_desc} — полный текст, поддерживает markdown-рендеринг (bold, списки, параграфы)
    → Кнопка "Показать полностью" если текст > 400 символов (спойлер)

Блок 4 — Чему научится ученик:
    → H2: "Что получит ребёнок / ученик"
    → {item_outcomes} — оформлен как список ✓ с иконками
    → (если item_outcomes пустой — блок скрывается)

Блок 5 — Параметры курса:
    → H2: "Параметры"
    → Таблица характеристик:
        Формат:        Онлайн / Офлайн / Гибрид
        Тип занятия:   Группа / Мини-группа / Индивидуально
        Язык:          Русский
        Время:         Вечер (17:00–22:00)
        Возраст:       7–14 лет
        Длительность:  60 минут / занятие

Блок 6 — Преподаватели:
    → H2: "Преподаватели"
    → Карточки преподавателей (из ItemPerformerLink → PerformerProfile):
        фото (или аватар-заглушка)
        имя + фамилия
        специализация
        опыт: {N} лет
        bio (до 200 символов, "читать далее" если длиннее)
    → Если преподавателей нет — блок скрывается
    → Если PerformerProfile.is_active = false — преподаватель не показывается

Блок 7 — Карта (только если studyformat = offline или hybrid):
    → H2: "Где проходят занятия"
    → QadamMap с маркером на ItemLocation.latitude / ItemLocation.longitude
    → Popup маркера: {full_address} (только если display_publicly = true)
    → Если display_publicly = false: карта с маркером на уровне города,
      без точного адреса
    → Если studyformat = online: блок скрывается полностью

Блок 8 — Отзывы:
    → H2: "Отзывы" + средняя оценка ★★★★☆ 4.3 / 5
    → ReviewsBlock: список отзывов (Spec 08)
    → Если отзывов нет: "Отзывов пока нет. Будьте первым!"
      Кнопка "Оставить отзыв" (только для авторизованных Buyer)
    → Пагинация отзывов: показываем первые 5, кнопка "Загрузить ещё"

Блок 9 — Похожие курсы (SimilarItems):
    → H2: "Похожие курсы"
    → Горизонтальная карусель из до 6 CourseCard
    → Алгоритм подбора — см. UC-01 "Алгоритм похожих"
    → Если похожих нет — блок скрывается

[ПРАВАЯ КОЛОНКА — OfferSidebar, sticky на desktop]
────────────────────────────────────────────────────────
→ Блок: "Стоимость"
  Матрица цен из ItemPriceVariant:
    За занятие:      25 000 сум
    За месяц:        180 000 сум  ← highlighted (is_highlighted = true)
    Пакет 8 занятий: 160 000 сум
  Если вариантов нет — "Уточняйте стоимость"

→ Блок: "Спецпредложения" (если есть активные ItemSpecialOffer):
    🔥 Скидка 10% при записи до 31 марта
    Таймер обратного отсчёта (если ends_at указан)

→ Кнопка CTA: "Записаться на курс" (большая, primary)
→ Под кнопкой: "Бесплатная консультация · Без предоплаты"

→ Блок: "Контакты продавца" (опционально — если продавец разрешил показ):
    Телефон, ссылки на соцсети (из SchoolProfile / OnlineSchoolProfile /
    IndividualContributorProfile)

Mobile: OfferSidebar фиксируется внизу экрана (fixed bottom bar):
    "от 25 000 сум · Записаться →"
    При клике разворачивается полная матрица цен или сразу открывает LeadModal
```

**Алгоритм похожих курсов (GET /api/items/:slug/similar):**
```
Priority 1: тот же subject_id + тот же studyformat + другой seller
Priority 2: тот же subject_id + другой studyformat
Priority 3: соседние subject из той же top-level категории
Исключить: сам айтем, айтемы того же seller (не путаем с конкурентами),
           айтемы с moderation_status != active или item_isvisible = false
Лимит: max 6 айтемов
```

---

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

**1a. cover_image_url = null (продавец не загрузил обложку):**
```
UI-реакция:
→ Вместо фото: placeholder-изображение с иконкой 🎓 и названием категории
→ Страница работает нормально, ошибок нет
→ Галерея не показывается (нет ни обложки, ни доп. фото)
```

**1b. ItemMedia пустой (нет ни фото, ни видео):**
```
UI-реакция:
→ Блок галереи заменяется одной cover_image (или placeholder)
→ Thumbnail-ряд не отображается
```

**1c. Карта не загрузилась (OSM/Nominatim недоступен):**
```
UI-реакция:
→ Вместо карты: серый блок-заглушка
→ Под блоком: "Карта временно недоступна."
→ Если есть full_address и display_publicly = true:
    показываем текстовый адрес: 📍 {full_address}
→ Остаток страницы загружается нормально
```

**1d. Нет ни одного активного спецпредложения (ItemSpecialOffer):**
```
UI-реакция:
→ Блок "Спецпредложения" в OfferSidebar не отображается
→ Не показываем пустой блок
```

**1e. PerformerProfile.is_active = false для всех преподавателей айтема:**
```
UI-реакция:
→ Блок "Преподаватели" скрывается полностью
→ Не показываем пустой блок с заголовком
```

**1f. ItemPriceVariant пустой (цены не заданы):**
```
UI-реакция:
→ В OfferSidebar: "Стоимость: уточняйте у организации"
→ Кнопка "Записаться" остаётся активной
```

**1g. Таймер спецпредложения истёк (ends_at < now()), но is_active = true:**
```
Поведение сервера:
→ ends_at < now() → спецпредложение НЕ возвращается в ответе /api/items/:slug
→ Проверка выполняется на уровне SQL: WHERE is_active = true AND (ends_at IS NULL OR ends_at > now())
→ UI: устаревших предложений нет — фронтенд не обрабатывает этот кейс
```

**1h. Загрузка страницы занимает > 3 секунд (медленное соединение):**
```
UI-реакция:
→ SSR-ответ отправляется как только данные готовы (потоковый HTML с Suspense)
→ OfferSidebar и заголовок рендерятся первыми (above the fold)
→ Галерея, карта, блок отзывов и SimilarItems — с loading skeleton-ами
→ SimilarItems загружается отдельным клиентским fetch (не блокирует основной SSR)
```

---

### UC-02: Пользователь нажимает "Записаться на курс" → открывается LeadModal

**Актор:** Гость или авторизованный Buyer
**Предусловие:** Страница айтема загружена
**Триггер:** Пользователь нажимает CTA-кнопку "Записаться на курс" в OfferSidebar

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

```
[Точка входа]
→ Пользователь находится на /item/[slug]
→ Видит кнопку "Записаться на курс" в правом сайдбаре (desktop)
  или в фиксированном баре внизу экрана (mobile)
→ Нажимает кнопку

→ Открывается LeadModal (Spec 07):
    - Модал поверх страницы (backdrop затемняет фон)
    - Страница за модалом остаётся доступной (не перезагружается)
    - В модал передаются: item_id, item_name, seller_id
    - Форма LeadModal содержит поля лида (имя, телефон, комментарий)
    - Полная логика отправки лида → Spec 07

→ После успешной отправки лида (из Spec 07):
    Модал закрывается
    На странице айтема: Toast (зелёный) "Заявка отправлена!
    Продавец свяжется с вами в ближайшее время."
    Кнопка "Записаться" не меняет состояние
    (байер может записаться повторно)
```

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

**2a. Пользователь закрывает модал без отправки:**
```
Поведение:
→ Нажимает ✕ или backdrop за модалом
→ Модал закрывается
→ Страница айтема остаётся без изменений
→ Данные из незаполненной формы не сохраняются
```

**2b. Скролл страницы при открытом модале:**
```
→ body.overflow = hidden при открытом модале (скролл заблокирован)
→ При закрытии модала: overflow восстанавливается, позиция скролла сохранена
```

---

### UC-03: Пользователь кликает на преподавателя

**Актор:** Гость
**Предусловие:** Страница айтема загружена, блок "Преподаватели" виден
**Триггер:** Пользователь нажимает на карточку преподавателя

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

```
[Точка входа]
→ Пользователь видит блок "Преподаватели" с карточками
→ Нажимает на карточку преподавателя или на его имя

Вариант A — Bio достаточно короткое (≤ 200 символов):
→ Вся информация уже видна в карточке — нет дополнительного действия

Вариант B — Bio длинное (> 200 символов):
→ Bio обрезано с "читать далее"
→ Клик на "читать далее" → текст bio разворачивается inline
→ Появляется ссылка "Свернуть"

Вариант C — Пользователь хочет посмотреть все курсы этого преподавателя:
→ Под карточкой преподавателя: ссылка "Ещё курсы этого преподавателя"
→ Переход на /sellers/[seller_id]#performers (якорная ссылка на Spec 12)
```

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

**3a. Фото преподавателя не загрузилось (photo_url недоступен):**
```
UI-реакция:
→ Вместо фото: аватар-заглушка с инициалами (первые буквы имени и фамилии)
→ Остаток карточки преподавателя отображается нормально
```

---

### UC-04: Пользователь нажимает на имя продавца → переходит на /sellers/[id]

**Актор:** Гость
**Предусловие:** Страница айтема загружена
**Триггер:** Пользователь нажимает на имя/название продавца в шапке страницы

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

```
[Точка входа]
→ В шапке страницы под item_name: "Курс от [Название школы]"
  (текст — ссылка, подчёркнута при hover)
→ Или логотип продавца (если есть logo_url) — тоже ссылка

→ Пользователь нажимает
→ Переход на /sellers/{seller_id} (публичный профиль продавца, Spec 12)
→ Переход в новой вкладке (target="_blank") чтобы байер не терял страницу айтема
   → TBD: открывать в текущей вкладке или новой? По умолчанию — в текущей
```

---

### UC-05: Айтем не найден → 404 страница

**Актор:** Гость
**Предусловие:** Пользователь переходит по slug, которого не существует
**Триггер:** GET /api/items/:slug возвращает 404

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

```
[Точка входа]
→ Пользователь вводит qadam.uz/item/nesushchestvuyushchiy-kurs
  или переходит по устаревшей ссылке

→ Сервер: SELECT ... WHERE item_slug = 'nesushchestvuyushchiy-kurs'
  → Не найдено → 404

→ Next.js возвращает 404 статус
→ Рендерится кастомная 404-страница (pages/404.tsx или notFound() в App Router):
    [Иллюстрация]
    "Страница не найдена"
    "Возможно, курс был удалён или ссылка устарела."
    Кнопки:
    [← Вернуться в каталог]  [Написать в поддержку]

→ HTTP статус: 404 (важно для SEO — не 200, не 301)
```

---

### UC-06: Айтем в статусе pending/draft → редирект или 404

**Актор:** Гость
**Предусловие:** Айтем существует, но moderation_status != active ИЛИ item_isvisible = false
**Триггер:** Пользователь переходит по прямой ссылке на айтем

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

```
[Точка входа]
→ Пользователь переходит на /item/[slug]
→ Сервер находит айтем, но:
    moderation_status IN ('draft', 'pending', 'rejected', 'archived')
    ИЛИ item_isvisible = false

→ Поведение (зависит от статуса):

  draft / pending:
  → Не 404 (айтем существует), но публично недоступен
  → Ответ: 404 (item not publicly available)
  → Рендерится кастомная страница:
    "Этот курс ещё не опубликован или находится на проверке."
    Кнопка: [Посмотреть похожие курсы → /]
  → HTTP статус: 404

  rejected / archived:
  → Аналогично: 404 с сообщением
    "Этот курс больше не доступен."
    Кнопка: [← Вернуться в каталог]

  item_isvisible = false (продавец скрыл айтем):
  → Аналогично draft/pending

→ Важно: seller-owner при авторизации переходит на /seller/items/[id] (edit)
  НЕ через публичный маршрут /item/[slug]
```

---

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

### Таблица правил видимости

| Статус | item_isvisible | Поведение |
|--------|---------------|----------|
| active | true | Страница доступна публично ✓ |
| active | false | 404 (скрыт продавцом) |
| pending | true/false | 404 (на модерации) |
| draft | true/false | 404 (черновик) |
| rejected | true/false | 404 (отклонён) |
| archived | true/false | 404 (архивирован) |

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

1. **Единственная точка входа — slug:** Айтем идентифицируется по `item_slug`, не по `item_id`. Slug уникален, неизменяем после первой публикации. При необходимости смены slug — редирект 301 от старого к новому.
2. **Публичность адреса на карте:** Карта с точным адресом отображается только если `ItemLocation.display_publicly = true`. Иначе — или нет карты, или карта с маркером на уровне города.
3. **Спецпредложения:** Только `is_active = true AND (ends_at IS NULL OR ends_at > NOW()) AND show_on_site = true` попадают в публичный ответ API.
4. **Преподаватели:** Только `PerformerProfile.is_active = true` отображаются на странице. Заблокированные сотрудники (staff_status = blocked) не видны.
5. **Похожие курсы:** Айтемы того же продавца в блок "Похожие" не попадают. Максимум 6.
6. **SEO:** Каждая страница айтема должна иметь уникальный title, description, og:image и JSON-LD. Отсутствие cover_image не блокирует публикацию — og:image в этом случае = лого платформы.
7. **SimilarItems загружается отдельным запросом:** Не блокирует основной SSR. Если похожие не загрузились — блок скрывается без ошибки.
8. **Отзывы:** Первые 5 отзывов загружаются вместе с основным SSR. Пагинация отзывов — клиентский fetch.
9. **Кнопка "Записаться" всегда активна:** Даже если нет цены. Байер может уточнить стоимость через LeadModal.

---

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

Страница айтема агрегирует данные из множества существующих моделей. Новых моделей не создаётся.

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

| Атрибут | Тип | Описание |
|---------|-----|---------|
| item_id | UUID | PK |
| seller_id | UUID FK | → Seller |
| item_name | string | Заголовок H1 страницы |
| item_slug | string | URL-параметр, unique |
| item_shortdesc | string | meta description, og:description |
| item_desc | text | Полное описание с markdown |
| item_outcomes | text? | Блок "Чему научится" |
| subject_id | UUID FK | → subject_registry (для breadcrumbs и похожих) |
| item_studytype | StudyType | group / mini_group / individual |
| item_studyformat | StudyFormat | online / offline / hybrid (управляет видимостью карты) |
| item_language | Language | |
| item_timeslot | TimeslotEnum[] | morning / afternoon / evening |
| item_age_from | int? | |
| item_age_to | int? | |
| item_duration_minutes | int? | Длительность занятия в минутах |
| item_price_from | Decimal? | Для og:price и OfferSidebar |
| item_price_to | Decimal? | |
| cover_image_url | string? | Главное фото, og:image |
| 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 | Управляет показом карты с точным адресом |

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

| Атрибут | Тип | Описание |
|---------|-----|---------|
| item_id | UUID FK | → Item |
| price_type | PriceType | per_lesson / per_month / subscription / package |
| lessons_count | int? | Для type = package |
| amount | Decimal | Цена |
| currency | string | UZS / USD |
| description | string? | Подпись варианта, напр. "8 занятий в месяц" |
| is_highlighted | boolean | Выделяется в матрице цен (рекомендуемый вариант) |

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

| Атрибут | Тип | Описание |
|---------|-----|---------|
| item_id | UUID FK | → Item |
| title | string | Заголовок спецпредложения |
| discount_type | DiscountType | percent / fixed_amount / gift |
| discount_value | Decimal | Значение скидки |
| condition | string | Условие получения |
| ends_at | DateTime? | Дедлайн. Если < now() — не отображается |
| is_active | boolean | |
| show_on_site | boolean | Контролирует показ на публичной странице |

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

| Атрибут | Тип | Описание |
|---------|-----|---------|
| item_id | UUID FK | → Item |
| media_type | MediaType | photo / video_link |
| url | string | URL фото в CDN или YouTube/Vimeo embed URL |
| sort_order | int | Порядок в галерее |

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

| Атрибут | Тип | Описание |
|---------|-----|---------|
| item_id | UUID FK | → Item |
| seller_staff_id | UUID FK | → SellerStaff |
| sort_order | int | Порядок в блоке преподавателей |

### ItemDetailPageDto (aggregated response object)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| item_id | UUID | |
| item_slug | string | |
| item_name | string | |
| item_shortdesc | string | |
| item_desc | string | |
| item_outcomes | string \| null | |
| parameters | ItemParametersDto | Формат, тип, язык, время, возраст, длит. |
| cover_image_url | string \| null | |
| media | ItemMediaDto[] | Фото и видео, отсортированы по sort_order |
| price_variants | ItemPriceVariantDto[] | |
| special_offers | ItemSpecialOfferDto[] | Только активные, ends_at > now() |
| performers | PerformerPublicDto[] | Только is_active = true |
| location | ItemLocationDto \| null | Только если studyformat != online |
| seller | ItemSellerDto | Имя, logo, seller_id для ссылки |
| avg_rating | Decimal \| null | |
| reviews_count | int | |

---

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

### 6.1 Prisma Schema

Новых моделей не создаётся. Страница использует существующие: Item, ItemLocation, ItemPriceVariant, ItemSpecialOffer, ItemMedia, ItemPerformerLink, PerformerProfile, SellerStaff, Seller, SchoolProfile, OnlineSchoolProfile, IndividualContributorProfile.

```prisma
// Вспомогательные enum (уже определены в Spec 02):

enum PriceType {
  per_lesson   // цена за одно занятие
  per_month    // цена в месяц
  per_package  // цена за пакет N занятий
  one_time     // разовая оплата за весь курс
}

enum DiscountType {
  percent
  fixed_amount
  gift
}

enum MediaType {
  photo
  video_link
}

// Модели используются без изменений:
// Item, ItemLocation, ItemPriceVariant, ItemSpecialOffer,
// ItemMedia, ItemPerformerLink, PerformerProfile
```

### 6.2 TypeScript DTO

```typescript
// ─── Response: полная страница айтема ─────────────────────────────────────

export interface ItemDetailPageDto {
  item_id:        string
  item_slug:      string
  item_name:      string
  item_shortdesc: string
  item_desc:      string
  item_outcomes:  string | null
  parameters:     ItemParametersDto
  cover_image_url: string | null
  media:          ItemMediaDto[]
  price_variants: ItemPriceVariantDto[]
  special_offers: ItemSpecialOfferDto[]
  performers:     PerformerPublicDto[]
  location:       ItemLocationPublicDto | null
  seller:         ItemSellerDto
  avg_rating:     number | null
  reviews_count:  number
}

export interface ItemParametersDto {
  studyformat:       'online' | 'offline' | 'hybrid'
  studytype:         'group' | 'mini_group' | 'individual'
  language:          string
  timeslot:          ('morning' | 'afternoon' | 'evening')[]
  age_from:          number | null
  age_to:            number | null
  duration_minutes:  number | null
}

export interface ItemMediaDto {
  media_type:  'photo' | 'video_link'
  url:         string
  sort_order:  number
}

export interface ItemPriceVariantDto {
  price_type:     'per_lesson' | 'per_month' | 'subscription' | 'package'
  lessons_count:  number | null
  amount:         number
  currency:       string
  description:    string | null
  is_highlighted: boolean
}

export interface ItemSpecialOfferDto {
  title:          string
  discount_type:  'percent' | 'fixed_amount' | 'gift'
  discount_value: number
  condition:      string
  ends_at:        string | null  // ISO 8601
}

export interface ItemLocationPublicDto {
  city:              string
  full_address:      string | null  // null если display_publicly = false
  latitude:          number | null  // null если display_publicly = false
  longitude:         number | null  // null если display_publicly = false
  display_publicly:  boolean
}

export interface ItemSellerDto {
  seller_id:    string
  seller_name:  string
  logo_url:     string | null
  seller_type:  'school_offline' | 'online_school' | 'individual_contributor'
}

// PerformerPublicDto — определён в Spec 03:
// { performer_id, first_name, last_name, specialization, experience_years, bio, photo_url }

// ─── Response: похожие айтемы ──────────────────────────────────────────────

export interface SimilarItemsResponse {
  items: CatalogItemCardDto[]  // тот же DTO что в Spec 05, max 6
}

// ─── JSON-LD (Course schema.org) ───────────────────────────────────────────

interface CourseJsonLd {
  '@context':    'https://schema.org'
  '@type':       'Course'
  name:          string  // item_name
  description:   string  // item_shortdesc
  provider: {
    '@type':  'Organization'
    name:     string  // seller_name
    url?:     string  // seller website если есть
  }
  image?:        string  // cover_image_url
  offers?: {
    '@type':      'Offer'
    price:        number  // item_price_from
    priceCurrency: 'UZS' | 'USD'
    availability: 'https://schema.org/InStock'
  }
  aggregateRating?: {
    '@type':      'AggregateRating'
    ratingValue:  number  // avg_rating
    reviewCount:  number  // reviews_count
  }
}
```

### 6.3 API Endpoints

```
────────────────────────────────────────────────────────────────
ПУБЛИЧНЫЙ API: КАРТОЧКА АЙТЕМА
────────────────────────────────────────────────────────────────

GET /api/items/:slug
Auth: Public
Path param: slug (string)
→ 200: ItemDetailPageDto
→ 404: { error: 'ITEM_NOT_FOUND' }
     Если: айтем не существует
       ИЛИ moderation_status != 'active'
       ИЛИ item_isvisible = false

Что возвращает:
  Основной айтем + все связанные данные в одном ответе:
    ItemPriceVariant: все варианты для этого item_id
    ItemSpecialOffer: WHERE is_active = true AND show_on_site = true
                      AND (ends_at IS NULL OR ends_at > NOW())
    ItemMedia: все медиа, ORDER BY sort_order ASC
    ItemPerformerLink: все performer-ы, ORDER BY sort_order ASC
      JOIN PerformerProfile WHERE is_active = true
      JOIN SellerStaff WHERE staff_status = 'active'
    ItemLocation: если studyformat IN ('offline', 'hybrid')
    Seller + профиль (для seller_name и logo_url)
    avg_rating, reviews_count: подзапрос к Review (Spec 08)

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

GET /api/items/:slug/similar
Auth: Public
Path param: slug (string)
Query: limit? (default: 6, max: 6)
→ 200: SimilarItemsResponse
→ 404: { error: 'ITEM_NOT_FOUND' }

Алгоритм на сервере:
  1. Получить subject_id и studyformat текущего айтема
  2. Query 1: same subject_id + same studyformat + другой seller_id
     ORDER BY reviews_count DESC LIMIT 6
  3. Если < 6 результатов: добавить Query 2:
     same subject_id + разный studyformat + другой seller_id
     (не дублировать айтемы из Query 1)
  4. Если всё ещё < 6: Query 3:
     same top-level category + другой seller_id
  5. Все результаты: WHERE moderation_status = 'active' AND item_isvisible = true
  6. LIMIT до 6 итого

────────────────────────────────────────────────────────────────
```

---

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

| Сценарий | Поведение |
|----------|----------|
| Slug содержит заглавные буквы (/item/English-Kurs) | Нормализация в lowercase при поиске. Редирект 301 к /item/english-kurs |
| Два айтема с одинаковым slug (race condition при создании) | Уникальный индекс в БД. Второй INSERT завершится ошибкой 409 (Spec 02) |
| cover_image_url недоступен (CDN down) | Браузер показывает broken image → обрабатывается через onError → placeholder |
| video_link — удалённое YouTube-видео | embed покажет "Видео недоступно". Не наша ответственность — продавец обновит ссылку |
| ItemPerformerLink.seller_staff_id → заблокированный сотрудник | staff_status = blocked → PerformerProfile.is_active = false → не показывается |
| SimilarItems запрос завис / timeout | Блок "Похожие курсы" не отображается. Основная страница не затронута |
| GET /api/items/:slug занял > 3 секунд | Streaming SSR: браузер начинает рендерить то, что готово. SimilarItems — отдельный запрос |
| ends_at спецпредложения прошёл между SSR и клиентом | Таймер на фронтенде дойдёт до 0, скроет предложение без перезагрузки страницы |
| Пользователь открыл страницу, айтем сняли с публикации | При следующем full-reload → 404. Текущий SSR-ответ остаётся активным в браузере |
| item_age_from = null, item_age_to = null | Блок возраста в параметрах не показывается |
| ItemLocation с coordinates [0, 0] | Bounds-проверка: lat 37–45.6, lon 55.9–73.2. Карта не показывается, текстовый адрес (если display_publicly = true) остаётся |
| seller_id не найден (orphan item) | 500 (data integrity error). Логируется как критическая ошибка. UI: generic error page |
| avg_rating при 0 отзывах | avg_rating = null, reviews_count = 0. Блок рейтинга не показывается. Блок "Отзывы" показывает empty state |
| Нет похожих айтемов ни по одному критерию | SimilarItemsResponse { items: [] }. Блок "Похожие курсы" не рендерится |

---

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

| Тема | Статус | Примечание |
|------|--------|-----------|
| Сохранение в избранное | Исключено из MVP | Требует buyer-аккаунт и UI закладок — v1.0 |
| Шаринг в соцсети (кнопки) | Исключено из MVP | og-теги достаточны для MVP. Кнопки — v1.0 |
| Просмотр продавцом preview своего айтема | TBD | Продавец может перейти на /item/[slug] как обычный пользователь. Специального "preview mode" нет — TBD |
| Переключатель валюты (UZS / USD) | Исключено из MVP | Одна валюта на выбор продавца. Конвертация — v1.0 |
| Открытие seller profile в новой вкладке vs текущей | TBD | По умолчанию: та же вкладка. Требует UX-решения |
| Breadcrumbs с вложенными подкатегориями | TBD | MVP: Главная → {Категория} (top-level). Subcategory в breadcrumbs — v1.0 |
| Structured data: BreadcrumbList JSON-LD | TBD | Course JSON-LD в MVP. BreadcrumbList — v1.0 |
| SimilarItems алгоритм — машинное обучение | Исключено | Rule-based алгоритм на MVP. ML-рекомендации — v1.5 |
| Кеширование страницы айтема (Redis / CDN) | TBD | SSR без кэша на MVP. Stale-while-revalidate или ISR — v1.0 |
| Аналитика просмотров страницы (view_count) | Исключено из MVP | Нет таблицы ItemView. Аналитика — v1.5 |
| Видеогалерея — загрузка файла (не ссылки) | Исключено из MVP | Только YouTube/Vimeo embed. Загрузка видео — v1.5 |
| PDF-брошюра о курсе | Исключено из MVP | Не входит в scope |

---

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

| Модуль | Связь |
|--------|-------|
| **Spec 02** (Item Management) | Источник всех данных айтема: Item, ItemPriceVariant, ItemSpecialOffer, ItemMedia, ItemPerformerLink |
| **Spec 03** (Staff Management) | PerformerProfile для блока преподавателей. is_active контролирует показ |
| **Spec 04** (Admin / Moderation) | moderation_status = active — обязательное условие публичного доступа |
| **Spec 05** (Catalog & Search) | Пользователь приходит из каталога. SimilarItems использует CatalogItemCardDto |
| **Spec 07** (Lead / LeadModal) | Кнопка "Записаться" открывает LeadModal. Item_id и seller_id передаются в модал |
| **Spec 08** (Reviews) | avg_rating и reviews_count агрегируются из таблицы Review. ReviewsBlock — компонент из Spec 08 |
| **Spec 12** (Public Seller Profile) | Ссылка на seller_name → /sellers/[seller_id] |
| **Spec 01** (Seller Profile) | ItemLocation.display_publicly задаётся при создании айтема на основе настроек продавца |
| **Инфраструктура: OpenStreetMap** | QadamMap для рендеринга карты с маркером (lazy, ssr:false) |
| **Инфраструктура: CDN** | cover_image_url и media URL хранятся в CDN. Провайдер TBD |
