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 | Поведение |
|---|---|---|
| active | true | Страница доступна публично ✓ |
| active | false | 404 (скрыт продавцом) |
| pending | true/false | 404 (на модерации) |
| draft | true/false | 404 (черновик) |
| rejected | true/false | 404 (отклонён) |
| archived | true/false | 404 (архивирован) |
Бизнес-правила
- Единственная точка входа — slug: Айтем идентифицируется по
item_slug, не поitem_id. Slug уникален, неизменяем после первой публикации. При необходимости смены slug — редирект 301 от старого к новому. - Публичность адреса на карте: Карта с точным адресом отображается только если
ItemLocation.display_publicly = true. Иначе — или нет карты, или карта с маркером на уровне города. - Спецпредложения: Только
is_active = true AND (ends_at IS NULL OR ends_at > NOW()) AND show_on_site = trueпопадают в публичный ответ API. - Преподаватели: Только
PerformerProfile.is_active = trueотображаются на странице. Заблокированные сотрудники (staff_status = blocked) не видны. - Похожие курсы: Айтемы того же продавца в блок "Похожие" не попадают. Максимум 6.
- SEO: Каждая страница айтема должна иметь уникальный title, description, og:image и JSON-LD. Отсутствие cover_image не блокирует публикацию — og:image в этом случае = лого платформы.
- SimilarItems загружается отдельным запросом: Не блокирует основной SSR. Если похожие не загрузились — блок скрывается без ошибки.
- Отзывы: Первые 5 отзывов загружаются вместе с основным SSR. Пагинация отзывов — клиентский fetch.
- Кнопка "Записаться" всегда активна: Даже если нет цены. Байер может уточнить стоимость через 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.
// Вспомогательные 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 |
| Шаринг в соцсети (кнопки) | Исключено из 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 |