Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-12-public-seller-profile.md

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, /reviews endpoint-ов в этом документе нужно считать историческим 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 = active404 для blocked / archived
Видимость адресовОтображается только SellerAddress с display_publicly = trueАдреса с display_publicly = false не передаются в публичный API
Видимость курсовТолько Item с item_status = activeНеактивные айтемы не включаются в выдачу
Видимость преподавателейТолько PerformerProfile с is_active = trueЗаблокированные сотрудники не отображаются
Видимость отзывовТолько Review с status = publishedPending / 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)

СущностьАтрибуты (задействованные)Источник
Sellerseller_id, seller_type, account_statusSpec 01
SchoolProfileorg_name, short_desc, full_desc, phone, email, website, instagram, logo_urlSpec 01
OnlineSchoolProfileorg_name, short_desc, full_desc, phone, email, website, instagram, logo_urlSpec 01
IndividualContributorProfilefirst_name, last_name, tagline, bio, work_format, photo_urlSpec 01
SellerAddresscity, full_address, latitude, longitude, display_publicly, is_primarySpec 01
SellerSubjectLink → Subjectsubject_id, nameSpec 01
Itemitem_id, slug, title, cover_url, price_from, price_to, item_statusSpec 02
PerformerProfileperformer_id, specialization, experience_years, bio, photo_url, is_activeSpec 03
SellerStafffirst_name, last_name, staff_statusSpec 03
Reviewreview_id, item_id, rating, text, status, seller_reply, seller_reply_at, created_atSpec 14
current_seller_review_statsseller_id, rating_avg, reviews_countSpec 14 (SAL)
current_item_review_statsitem_id, rating_avg, reviews_countSpec 14 (SAL)

SellerPublicDto (агрегированный публичный ответ)

АтрибутТипОписание
seller_idstring (UUID)Идентификатор
seller_typeSellerTypeschool_offline / online_school / individual_contributor
profileSchoolPublicDto | OnlineSchoolPublicDto | IndividualPublicDtoЗависит от типа
addressesSellerAddressPublicDto[]Только display_publicly = true
subjectsSubjectDto[]Все категории продавца
review_statsSellerReviewStatsDtorating_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 = nullaggregateRating не включается в JSON-LD

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

ТемаСтатусПримечание
Фильтрация курсов по категории на странице профиляИсключено из MVPФильтры каталога — v1.0
Сортировка отзывов (по рейтингу, по дате)Исключено из MVPТолько по дате DESC
Кнопка "Пожаловаться на профиль"TBDЖалоба на профиль продавца — v1.0
Карта: провайдер (Google Maps vs Yandex Maps)TBDНе определён
Карта: кластеризация при множестве пиновTBDНужна при > 5 адресах
Счётчик просмотров профиляИсключено из MVPAnalytics — v1.5
Кнопка "Поделиться профилем"Исключено из MVPWeb Share API — v1.0
Кэширование публичного профиля (Redis / CDN)TBDСтратегия кэширования не определена
SSR vs SSG для /sellers/[id]TBDISR (Incremental Static Regeneration) предпочтительно — обсудить
Изображение преподавателя: thumbnailTBDАналогично Spec 03
Верификационный бейдж продавцаИсключено из MVPTrust-сигналы — 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 компонент переиспользуется