# MVP Spec 12 — Public Seller Profile

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

- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
  - [Product roadmap и delivery checklist](../product-roadmap.md)
  - [Roadmap](../../project/roadmap.md)
  - [Карта API-маршрутов](../../architecture/api-routes.md)

> Version: MVP · Priority: P0 · Phase: B (Demand)
> Status: Draft v1
> 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 = 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

```typescript
// ─── Публичный профиль продавца ───────────────────────────────────────────

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 компонент переиспользуется |
