Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-06-item-detail-card.md

MVP Spec 06 — Item Detail Card (Public Page)

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

MVP Spec 06 — Item Detail Card (Public Page)

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

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

Страница айтема /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Поведение
activetrueСтраница доступна публично ✓
activefalse404 (скрыт продавцом)
pendingtrue/false404 (на модерации)
drafttrue/false404 (черновик)
rejectedtrue/false404 (отклонён)
archivedtrue/false404 (архивирован)

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

  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_idUUIDPK
seller_idUUID FK→ Seller
item_namestringЗаголовок H1 страницы
item_slugstringURL-параметр, unique
item_shortdescstringmeta description, og:description
item_desctextПолное описание с markdown
item_outcomestext?Блок "Чему научится"
subject_idUUID FK→ subject_registry (для breadcrumbs и похожих)
item_studytypeStudyTypegroup / mini_group / individual
item_studyformatStudyFormatonline / offline / hybrid (управляет видимостью карты)
item_languageLanguage
item_timeslotTimeslotEnum[]morning / afternoon / evening
item_age_fromint?
item_age_toint?
item_duration_minutesint?Длительность занятия в минутах
item_price_fromDecimal?Для og:price и OfferSidebar
item_price_toDecimal?
cover_image_urlstring?Главное фото, og:image
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Управляет показом карты с точным адресом

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

АтрибутТипОписание
item_idUUID FK→ Item
price_typePriceTypeper_lesson / per_month / subscription / package
lessons_countint?Для type = package
amountDecimalЦена
currencystringUZS / USD
descriptionstring?Подпись варианта, напр. "8 занятий в месяц"
is_highlightedbooleanВыделяется в матрице цен (рекомендуемый вариант)

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

АтрибутТипОписание
item_idUUID FK→ Item
titlestringЗаголовок спецпредложения
discount_typeDiscountTypepercent / fixed_amount / gift
discount_valueDecimalЗначение скидки
conditionstringУсловие получения
ends_atDateTime?Дедлайн. Если < now() — не отображается
is_activeboolean
show_on_sitebooleanКонтролирует показ на публичной странице

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

АтрибутТипОписание
item_idUUID FK→ Item
media_typeMediaTypephoto / video_link
urlstringURL фото в CDN или YouTube/Vimeo embed URL
sort_orderintПорядок в галерее

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

АтрибутТипОписание
item_idUUID FK→ Item
seller_staff_idUUID FK→ SellerStaff
sort_orderintПорядок в блоке преподавателей

ItemDetailPageDto (aggregated response object)

АтрибутТипОписание
item_idUUID
item_slugstring
item_namestring
item_shortdescstring
item_descstring
item_outcomesstring | null
parametersItemParametersDtoФормат, тип, язык, время, возраст, длит.
cover_image_urlstring | null
mediaItemMediaDto[]Фото и видео, отсортированы по sort_order
price_variantsItemPriceVariantDto[]
special_offersItemSpecialOfferDto[]Только активные, ends_at > now()
performersPerformerPublicDto[]Только is_active = true
locationItemLocationDto | nullТолько если studyformat != online
sellerItemSellerDtoИмя, logo, seller_id для ссылки
avg_ratingDecimal | null
reviews_countint

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

6.1 Prisma Schema

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

// Вспомогательные 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

// ─── 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
Шаринг в соцсети (кнопки)Исключено из MVPog-теги достаточны для MVP. Кнопки — v1.0
Просмотр продавцом preview своего айтемаTBDПродавец может перейти на /item/[slug] как обычный пользователь. Специального "preview mode" нет — TBD
Переключатель валюты (UZS / USD)Исключено из MVPОдна валюта на выбор продавца. Конвертация — v1.0
Открытие seller profile в новой вкладке vs текущейTBDПо умолчанию: та же вкладка. Требует UX-решения
Breadcrumbs с вложенными подкатегориямиTBDMVP: Главная → {Категория} (top-level). Subcategory в breadcrumbs — v1.0
Structured data: BreadcrumbList JSON-LDTBDCourse JSON-LD в MVP. BreadcrumbList — v1.0
SimilarItems алгоритм — машинное обучениеИсключеноRule-based алгоритм на MVP. ML-рекомендации — v1.5
Кеширование страницы айтема (Redis / CDN)TBDSSR без кэша на 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 задаётся при создании айтема на основе настроек продавца
Инфраструктура: OpenStreetMapQadamMap для рендеринга карты с маркером (lazy, ssr:false)
Инфраструктура: CDNcover_image_url и media URL хранятся в CDN. Провайдер TBD