MVP Spec 12 — Public Seller Profile
Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев
MVP Spec 12 — Public Seller Profile
Паспорт документа
- Статус документа: 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 Sync note, 28 Mar 2026:
- live public endpoint is
GET /api/v1/sellers/:id;- текущая реализация отдаёт агрегированный seller profile одним endpoint, поэтому старые примеры отдельных
/public,/items,/performers,/reviewsendpoint-ов в этом документе нужно считать историческим draft, а не текущим API truth.
1. Контекст и цель
Публичная страница продавца /sellers/[id] — это витрина образовательной организации на платформе Qadam. Это первая точка контакта покупателя с конкретной школой или репетитором: он видит все курсы, преподавателей, отзывы и контактные данные в одном месте.
Цель модуля: предоставить покупателю исчерпывающую информацию об образовательном провайдере, повысить доверие через отзывы и публичные профили преподавателей, обеспечить SEO-трафик на профили продавцов.
Что не входит в этот модуль:
- Онбординг и редактирование профиля продавца → Spec 01
- Создание и управление курсами → Spec 02
- Система отзывов (написание, модерация) → Spec 14
- Страница конкретного курса
/item/[slug]→ Spec 02 - Каталог и поиск курсов → Spec v1.0
2. Роли пользователей
| Роль | Действия в этом модуле |
|---|---|
| Гость (неавторизованный) | Просматривает профиль, курсы, преподавателей, отзывы; переходит на страницы курсов |
| Покупатель (авторизованный) | Те же действия, что и Гость; дополнительно видит CTA "Оставить заявку" на курсах |
| Система | Формирует мета-теги, JSON-LD, Open Graph |
3. Use Cases
UC-01: Гость просматривает профиль школы (тип school_offline)
Актор: Гость / Покупатель
Предусловие: Продавец существует, account_status = active, тип = school_offline
Триггер: Переходит по ссылке /sellers/[id] (из каталога, поиска или прямой ссылки)
Полный поток:
[Точка входа]
→ Пользователь переходит на /sellers/[id]
→ Сервер запрашивает данные продавца через GET /api/v1/sellers/:id
─────────────────────────────────────────────────────────
СЕКЦИЯ 1 — Шапка профиля (Header)
─────────────────────────────────────────────────────────
→ Отображается:
- Логотип школы (logo_url) или заглушка-аватар если не загружен
- Название организации (SchoolProfile.org_name)
- Предметные категории в виде бейджей:
[Английский] [Программирование] [Математика]
(из SellerSubjectLink → subjects)
- Рейтинг: звёзды (current_seller_review_stats.rating_avg,
округлённый до 1 знака, напр. "4.7 ★")
- Количество отзывов: "(142 отзыва)"
- Краткое описание (SchoolProfile.short_desc)
─────────────────────────────────────────────────────────
СЕКЦИЯ 2 — О школе (About)
─────────────────────────────────────────────────────────
→ Отображается:
- Полное описание (SchoolProfile.full_desc)
Если текст > 400 символов: показывается первые 400,
кнопка "Показать полностью" разворачивает весь текст
- Контакты:
Телефон: +998 90 123-45-67 (кликабельный tel:)
Email: school@example.com (кликабельный mailto:)
Сайт: example.com (внешняя ссылка, target="_blank", rel="noopener")
Instagram: @school_insta (внешняя ссылка)
- Контакты отображаются только если поле заполнено
─────────────────────────────────────────────────────────
СЕКЦИЯ 3 — Адреса (только для school_offline)
─────────────────────────────────────────────────────────
→ Отображается если display_publicly = true:
- Карта (Google Maps / Yandex Maps embed) с пинами
для каждого адреса с display_publicly = true
- Под картой — список адресов:
[Основной] г. Ташкент, ул. Навои, 12
г. Ташкент, ул. Амира Темура, 55
- Первичный адрес (is_primary = true) помечен бейджем [Основной]
→ Если нет ни одного публичного адреса — секция не отображается
─────────────────────────────────────────────────────────
СЕКЦИЯ 4 — Курсы (Active Courses Grid)
─────────────────────────────────────────────────────────
→ Заголовок: "Курсы"
→ Сетка карточек (тот же компонент CourseCard что в каталоге):
Каждая карточка показывает:
- Обложка айтема
- Название айтема
- Цена / ценовой диапазон
- Рейтинг айтема (current_item_review_stats.rating_avg)
- Количество отзывов
- Кнопка "Подробнее" → ведёт на /item/[slug]
→ Показываются только айтемы с item_status = active
→ Количество на странице: до 12, "Показать ещё" если больше
→ Пустое состояние:
Иконка + "Пока нет активных курсов"
─────────────────────────────────────────────────────────
СЕКЦИЯ 5 — Преподаватели (Teaching Staff)
─────────────────────────────────────────────────────────
→ Заголовок: "Преподаватели"
→ Сетка карточек преподавателей:
Каждая карточка (PerformerPublicDto):
- Фото (photo_url) или аватар-заглушка
- Имя и фамилия
- Специализация (specialization)
- Опыт работы: "5 лет опыта" (если заполнен experience_years)
- Bio (до 100 символов, с "...")
→ Показываются только performer_profile.is_active = true
→ Если нет преподавателей — секция не отображается
─────────────────────────────────────────────────────────
СЕКЦИЯ 6 — Отзывы (Reviews)
─────────────────────────────────────────────────────────
→ Заголовок: "Отзывы" + общий рейтинг + итоговое количество
→ Список отзывов (только status = published):
Каждый отзыв:
- Аватар покупателя (заглушка) + имя (first_name + первая буква last_name + ".")
- Звёзды рейтинга (1–5)
- Дата написания (напр. "15 февраля 2026")
- Название курса (ссылка на /item/[slug])
- Текст отзыва
- Ответ продавца (если seller_reply заполнен):
Блок с фоном: "[Название школы] отвечает:"
Текст ответа
Дата ответа
→ Пагинация: по 10 отзывов, кнопка "Загрузить ещё"
→ Если нет отзывов:
"Пока нет отзывов о школе"
UC-01 — Альтернативные потоки и обработка ошибок
1a. Логотип не загружен:
UI-реакция:
→ Отображается заглушка: круглый аватар с первой буквой org_name
→ Цвет заглушки генерируется по seller_id (детерминировано)
1b. Данные ещё загружаются (скелетон):
UI-реакция:
→ Все секции показывают skeleton-loader (серые блоки-заглушки)
→ Spinner отсутствует — используется только skeleton
1c. Ошибка загрузки данных (сеть / 500):
UI-реакция:
→ Страница показывает:
Иконка предупреждения
"Не удалось загрузить профиль. Попробуйте обновить страницу."
Кнопка "Обновить"
→ Если при навигации назад — кнопка "На главную"
UC-02: Гость просматривает профиль онлайн-школы (тип online_school)
Актор: Гость / Покупатель
Предусловие: Продавец существует, account_status = active, тип = online_school
Триггер: Переходит на /sellers/[id]
Отличия от UC-01:
→ Секция "Адреса" ОТСУТСТВУЕТ (online_school не имеет физических адресов)
→ В шапке: нет иконки геолокации
→ Профиль использует OnlineSchoolProfile (те же поля кроме адреса)
→ Все остальные секции идентичны UC-01
UC-03: Гость просматривает профиль индивидуального репетитора (тип individual_contributor)
Актор: Гость / Покупатель
Предусловие: Продавец существует, account_status = active, тип = individual_contributor
Триггер: Переходит на /sellers/[id]
Отличия от UC-01:
─────────────────────────────────────────────────────────
СЕКЦИЯ 1 — Шапка профиля (Header) — отличия
─────────────────────────────────────────────────────────
→ Вместо логотипа: фото репетитора (photo_url из IndividualContributorProfile)
или круглый аватар-заглушка
→ Вместо org_name: "Имя Фамилия" (first_name + last_name)
→ Вместо short_desc: tagline (IndividualContributorProfile.tagline)
→ Бейдж формата работы: [Онлайн] / [Офлайн] / [Онлайн и офлайн]
(из work_format)
─────────────────────────────────────────────────────────
СЕКЦИЯ 2 — О репетиторе — отличия
─────────────────────────────────────────────────────────
→ Вместо full_desc: bio (IndividualContributorProfile.bio)
→ Контакты: те же поля (phone, email, website, instagram)
─────────────────────────────────────────────────────────
СЕКЦИЯ 3 — Адреса
─────────────────────────────────────────────────────────
→ Отображается если work_format = offline | both
→ Логика та же: только адреса с display_publicly = true
─────────────────────────────────────────────────────────
СЕКЦИЯ 5 — Преподаватели
─────────────────────────────────────────────────────────
→ ОТСУТСТВУЕТ — индивидуальный репетитор не имеет
отдельных преподавателей (он сам и есть преподаватель)
UC-04: Гость кликает на курс → переход на /item/[slug]
Актор: Гость / Покупатель Предусловие: Секция курсов отображается, есть активные курсы Триггер: Нажимает карточку курса или кнопку "Подробнее"
Полный поток:
→ Пользователь нажимает на CourseCard
→ Весь клик на карточке кликабелен (не только кнопка)
→ Переход на /item/[slug] (slug берётся из Item.slug)
→ Переход происходит в текущей вкладке (не target="_blank")
Альтернативный поток:
4a. Курс был деактивирован между загрузкой и кликом:
UI-реакция:
→ /item/[slug] возвращает 404 или показывает "Курс недоступен"
→ Ссылка "← Назад к профилю школы"
UC-05: Продавец не найден или заблокирован → 404
Актор: Гость / Покупатель
Триггер: Переходит на /sellers/[id] с несуществующим или заблокированным id
Полный поток:
→ GET /api/v1/sellers/:id
→ Сервер возвращает:
404 если seller_id не существует
404 если account_status = blocked или deleted
─────────────────────────────────────────────────────────
UI — страница 404
─────────────────────────────────────────────────────────
→ HTTP статус ответа: 404 (важно для SEO — не 200)
→ На странице:
Иллюстрация (404)
"Профиль не найден"
"Эта школа или репетитор не существует на платформе,
либо профиль был удалён."
Кнопка "Перейти в каталог" → /catalog
→ Нет редиректа на главную (пользователь остаётся на /sellers/[id])
UC-06: SEO — мета-теги и структурированные данные
Актор: Поисковый робот / Браузер
Предусловие: Продавец существует, account_status = active
Триггер: Рендер страницы /sellers/[id]
Полный поток:
─────────────────────────────────────────────────────────
META TAGS
─────────────────────────────────────────────────────────
→ <title>: "{org_name} — курсы и обучение | Qadam"
→ <meta name="description">:
Для school_offline/online_school: "{short_desc} · Рейтинг {rating_avg} · {reviews_count} отзывов"
Для individual_contributor: "{first_name} {last_name} — {tagline} · Qadam"
→ Canonical URL: https://qadam.uz/sellers/{id}
─────────────────────────────────────────────────────────
OPEN GRAPH
─────────────────────────────────────────────────────────
→ og:title: "{org_name}"
→ og:description: "{short_desc}"
→ og:image: {logo_url} или дефолтная OG-заглушка платформы
→ og:type: "profile"
→ og:url: "https://qadam.uz/sellers/{id}"
─────────────────────────────────────────────────────────
JSON-LD (Organization schema)
─────────────────────────────────────────────────────────
→ Для school_offline / online_school:
{
"@context": "https://schema.org",
"@type": "EducationalOrganization",
"name": "{org_name}",
"description": "{short_desc}",
"url": "https://qadam.uz/sellers/{id}",
"telephone": "{phone}",
"email": "{email}",
"sameAs": ["{instagram_url}", "{website}"],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "{rating_avg}",
"reviewCount": "{reviews_count}"
}
}
→ Для individual_contributor:
{
"@context": "https://schema.org",
"@type": "Person",
"name": "{first_name} {last_name}",
"description": "{tagline}",
"url": "https://qadam.uz/sellers/{id}"
}
→ JSON-LD встраивается в <head> через <script type="application/ld+json">
→ JSON-LD не генерируется если продавец не найден (404)
4. Бизнес-правила и валидации
| Правило | Описание | Ошибка / Поведение |
|---|---|---|
| Видимость профиля | Страница доступна только если account_status = active | 404 для blocked / archived |
| Видимость адресов | Отображается только SellerAddress с display_publicly = true | Адреса с display_publicly = false не передаются в публичный API |
| Видимость курсов | Только Item с item_status = active | Неактивные айтемы не включаются в выдачу |
| Видимость преподавателей | Только PerformerProfile с is_active = true | Заблокированные сотрудники не отображаются |
| Видимость отзывов | Только Review с status = published | Pending / rejected / pending_moderation не показываются |
| Рейтинг продавца | Берётся из SAL: current_seller_review_stats.rating_avg | Пересчитывается асинхронно при изменении статуса отзыва |
| Рейтинг айтема | Берётся из SAL: current_item_review_stats.rating_avg | Пересчитывается асинхронно |
| Пагинация отзывов | 10 отзывов на запрос, cursor-based или offset | Порядок: created_at DESC |
| Пагинация курсов | До 12 на первый экран, кнопка "Показать ещё" | offset-based |
| SEO 404 | Несуществующий / заблокированный продавец → HTTP 404 | Не 200 с пустым контентом |
| individual_contributor без преподавателей | Секция "Преподаватели" не рендерится | Не отображать пустую секцию |
| Адреса для online_school | Секция "Адреса" не рендерится | Нет адресов в данных |
5. Модель данных
Этот модуль не создаёт новых сущностей — он только читает данные из существующих.
Используемые сущности (read-only)
| Сущность | Атрибуты (задействованные) | Источник |
|---|---|---|
| Seller | seller_id, seller_type, account_status | Spec 01 |
| SchoolProfile | org_name, short_desc, full_desc, phone, email, website, instagram, logo_url | Spec 01 |
| OnlineSchoolProfile | org_name, short_desc, full_desc, phone, email, website, instagram, logo_url | Spec 01 |
| IndividualContributorProfile | first_name, last_name, tagline, bio, work_format, photo_url | Spec 01 |
| SellerAddress | city, full_address, latitude, longitude, display_publicly, is_primary | Spec 01 |
| SellerSubjectLink → Subject | subject_id, name | Spec 01 |
| Item | item_id, slug, title, cover_url, price_from, price_to, item_status | Spec 02 |
| PerformerProfile | performer_id, specialization, experience_years, bio, photo_url, is_active | Spec 03 |
| SellerStaff | first_name, last_name, staff_status | Spec 03 |
| Review | review_id, item_id, rating, text, status, seller_reply, seller_reply_at, created_at | Spec 14 |
| current_seller_review_stats | seller_id, rating_avg, reviews_count | Spec 14 (SAL) |
| current_item_review_stats | item_id, rating_avg, reviews_count | Spec 14 (SAL) |
SellerPublicDto (агрегированный публичный ответ)
| Атрибут | Тип | Описание |
|---|---|---|
| seller_id | string (UUID) | Идентификатор |
| seller_type | SellerType | school_offline / online_school / individual_contributor |
| profile | SchoolPublicDto | OnlineSchoolPublicDto | IndividualPublicDto | Зависит от типа |
| addresses | SellerAddressPublicDto[] | Только display_publicly = true |
| subjects | SubjectDto[] | Все категории продавца |
| review_stats | SellerReviewStatsDto | rating_avg, reviews_count |
6. Технические контракты
6.1 Prisma Schema
Этот модуль не добавляет новых моделей — только читает. Новых миграций нет.
6.2 TypeScript DTOs
// ─── Публичный профиль продавца ───────────────────────────────────────────
export interface SchoolPublicDto {
org_name: string
short_desc: string | null
full_desc: string | null
phone: string | null
email: string | null
website: string | null
instagram: string | null
logo_url: string | null
}
export interface OnlineSchoolPublicDto extends SchoolPublicDto {}
export interface IndividualPublicDto {
first_name: string
last_name: string
tagline: string | null
bio: string | null
work_format: WorkFormat // online | offline | both
photo_url: string | null
}
export interface SellerAddressPublicDto {
city: string
full_address: string
latitude: number | null
longitude: number | null
is_primary: boolean
}
export interface SubjectDto {
subject_id: string
name: string
}
export interface SellerReviewStatsDto {
rating_avg: number | null // null если нет отзывов
reviews_count: number
}
export interface SellerPublicDto {
seller_id: string
seller_type: 'school_offline' | 'online_school' | 'individual_contributor'
profile: SchoolPublicDto | OnlineSchoolPublicDto | IndividualPublicDto
addresses: SellerAddressPublicDto[] // пусто для online_school
subjects: SubjectDto[]
review_stats: SellerReviewStatsDto
}
// ─── Курсы продавца (публичный список) ───────────────────────────────────
export interface CourseCardPublicDto {
item_id: string
slug: string
title: string
cover_url: string | null
price_from: number | null
price_to: number | null
review_stats: ItemReviewStatsDto
}
export interface ItemReviewStatsDto {
rating_avg: number | null
reviews_count: number
}
// ─── Отзывы (публичный список) ────────────────────────────────────────────
export interface ReviewPublicDto {
review_id: string
buyer_display_name: string // first_name + initial last_name
item_id: string
item_title: string
item_slug: string
rating: number
text: string
created_at: string // ISO 8601
seller_reply: string | null
seller_reply_at: string | null
}
export interface ReviewsPageResponse {
reviews: ReviewPublicDto[]
total: number
has_more: boolean
next_cursor: string | null
}
6.3 API Endpoints
────────────────────────────────────────────────────────────────
ПУБЛИЧНЫЙ API: СТРАНИЦА ПРОДАВЦА
────────────────────────────────────────────────────────────────
GET /api/v1/sellers/:seller_id
Auth: Public (no token required)
→ 200: SellerPublicDto
→ 404: { error: 'SELLER_NOT_FOUND' }
GET /api/v1/sellers/:seller_id/items
Auth: Public
Query: ?page=1&limit=12
→ 200: { items: CourseCardPublicDto[], total: number, has_more: boolean }
→ 404: { error: 'SELLER_NOT_FOUND' }
GET /api/v1/sellers/:seller_id/performers
Auth: Public
(определено в Spec 03)
→ 200: PerformerPublicDto[]
GET /api/v1/sellers/:seller_id/reviews
Auth: Public
Query: ?cursor=<review_id>&limit=10
→ 200: ReviewsPageResponse
→ 404: { error: 'SELLER_NOT_FOUND' }
7. Edge Cases
| Сценарий | Поведение |
|---|---|
| Продавец без логотипа / фото | Отображается аватар-заглушка с первой буквой имени |
| Продавец без описания (short_desc пустой) | Секция описания в шапке не рендерится |
| Продавец без курсов (все item_status != active) | Секция курсов показывает empty state "Пока нет активных курсов" |
| Продавец без адресов с display_publicly = true | Секция адресов не рендерится |
| Продавец без преподавателей (все is_active = false или нет teacher) | Секция преподавателей не рендерится |
| Продавец без отзывов | Секция отзывов показывает "Пока нет отзывов", рейтинг не отображается |
| rating_avg = null (нет published отзывов) | Звёзды и цифра рейтинга не отображаются; скрывается |
| Изображение логотипа недоступно (broken URL) | onError → заглушка-аватар |
| Изображение обложки курса недоступно | onError → серый placeholder |
| Coordinates null в SellerAddress | Пин на карте не добавляется для этого адреса |
| seller_id = валидный UUID, но seller не существует | 404 |
| Одновременная загрузка страницы и обновление статуса отзыва | SAL пересчитывается async; возможна рассинхронизация до следующего запроса |
| individual_contributor с work_format = online, но есть адреса | Секция адресов не рендерится (приоритет work_format) |
| Очень длинный org_name (> 100 символов) | CSS text-overflow: ellipsis в шапке; полное имя в title/og:title |
| JSON-LD при rating_avg = null | aggregateRating не включается в JSON-LD |
8. TBD / Сознательно опущено
| Тема | Статус | Примечание |
|---|---|---|
| Фильтрация курсов по категории на странице профиля | Исключено из MVP | Фильтры каталога — v1.0 |
| Сортировка отзывов (по рейтингу, по дате) | Исключено из MVP | Только по дате DESC |
| Кнопка "Пожаловаться на профиль" | TBD | Жалоба на профиль продавца — v1.0 |
| Карта: провайдер (Google Maps vs Yandex Maps) | TBD | Не определён |
| Карта: кластеризация при множестве пинов | TBD | Нужна при > 5 адресах |
| Счётчик просмотров профиля | Исключено из MVP | Analytics — v1.5 |
| Кнопка "Поделиться профилем" | Исключено из MVP | Web Share API — v1.0 |
| Кэширование публичного профиля (Redis / CDN) | TBD | Стратегия кэширования не определена |
| SSR vs SSG для /sellers/[id] | TBD | ISR (Incremental Static Regeneration) предпочтительно — обсудить |
| Изображение преподавателя: thumbnail | TBD | Аналогично Spec 03 |
| Верификационный бейдж продавца | Исключено из MVP | Trust-сигналы — v1.0 |
9. Зависимости
| Модуль | Связь |
|---|---|
| Spec 01 (Seller Onboarding) | SchoolProfile, OnlineSchoolProfile, IndividualContributorProfile, SellerAddress, SellerSubjectLink |
| Spec 02 (Item Management) | Item (active items grid), CourseCard компонент |
| Spec 03 (Staff Management) | PerformerProfile (teaching staff grid), PerformerPublicDto |
| Spec 14 (Reviews) | Review (published reviews), current_seller_review_stats, current_item_review_stats |
| Spec v1.0 (Catalog) | CourseCard компонент переиспользуется |