# Handoff для frontend по Spec 02a — Package Discounts & Promo Text

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

- Статус документа: working reference
- Актуально на: 15 апреля 2026 года
- Владелец: backend/platform-команда, совместно с frontend-командой
- Пересмотр: при изменении pricing-контрактов `ItemPriceVariant`, catalog price aggregation или seller price-variant CRUD
- Область применения: handoff backend-контракта скидок и promo text для frontend-команды `qadam-web`
- Связанные документы:
  - [Индекс документации](../README.md)
  - [frontend-handoff.md](./frontend-handoff.md)
  - [openapi.json](../../apps/api/openapi/openapi.json)
  - [2026-04-15-price-variants-legacy-cleanup.md](../backend/followups/2026-04-15-price-variants-legacy-cleanup.md)

- Статус: ready for frontend
- Актуально на: 15 апреля 2026
- Репозиторий: `qadam-core`
- Ветка: `dev`
- Источник истины: [openapi.json](../../apps/api/openapi/openapi.json)

## Что реализовано на backend

В `qadam-core` добавлен pricing-слой на уровне пакета занятий:

- новый Prisma model: `ItemPriceVariant`
- скидка хранится как `discountAmount`
- промо-текст хранится как `promoText`
- backend сам вычисляет и возвращает:
  - `totalPrice`
  - `discountPercent`
  - `discountedPrice`
  - `priceLabel`
- каталог теперь возвращает агрегированную минимальную цену по пакетам
- детальная страница айтема и seller detail возвращают полную матрицу пакетов

Важно:

- backend-контракт использует `camelCase`, не `snake_case`
- все пути идут через существующую схему `/api/v1/...`
- старая логика `special offers` не используется для price rendering

## 1. Модель пакета занятий

Каждый пакет содержит:

- `studyType`: `ONE_ON_ONE | MINI_GROUP | GROUP`
- `lessonsCount`
- `amount`
- `currency`
- `description`
- `isHighlighted`
- `sortOrder`
- `discountAmount`
- `promoText`

В response backend дополнительно вычисляет:

- `totalPrice`
- `discountPercent`
- `discountedPrice`
- `priceLabel`

### Форматы `studyType`

Backend уже возвращает готовый `priceLabel`, но фактический mapping такой:

- `ONE_ON_ONE` → `Персональные занятия`
- `MINI_GROUP` → `Занятия в мини-группах`
- `GROUP` → `Групповые занятия`

## 2. Seller endpoints

### Создать пакет

`POST /api/v1/seller/items/:id/price-variants`

Body:

```json
{
  "studyType": "ONE_ON_ONE",
  "lessonsCount": 5,
  "amount": 500000,
  "currency": "UZS",
  "description": "Индивидуальные занятия",
  "isHighlighted": false,
  "sortOrder": 0,
  "discountAmount": 136000,
  "promoText": "Скидка до сентября"
}
```

### Обновить пакет

`PATCH /api/v1/seller/items/:id/price-variants/:variantId`

Body partial:

```json
{
  "discountAmount": 136000,
  "promoText": "Скидка до сентября"
}
```

### Удалить пакет

`DELETE /api/v1/seller/items/:id/price-variants/:variantId`

### Response пакета

И `POST`, и `PATCH`, и `DELETE` возвращают один и тот же contract пакета:

```json
{
  "id": "uuid",
  "studyType": "ONE_ON_ONE",
  "lessonsCount": 5,
  "amount": 500000,
  "currency": "UZS",
  "description": "Индивидуальные занятия",
  "isHighlighted": false,
  "sortOrder": 0,
  "totalPrice": 2500000,
  "discountAmount": 136000,
  "discountPercent": 5,
  "discountedPrice": 2364000,
  "promoText": "Скидка до сентября",
  "priceLabel": "Персональные занятия",
  "createdAt": "2026-04-15T09:00:00.000Z",
  "updatedAt": "2026-04-15T09:00:00.000Z"
}
```

### Снятие скидки и промо

- снять скидку: `PATCH` с `{ "discountAmount": null }`
- снять промо: `PATCH` с `{ "promoText": null }`

## 3. Валидации backend

Backend уже валидирует:

- `amount > 0`
- `lessonsCount >= 1`
- `discountAmount > 0`, если передан
- `discountAmount < totalPrice`
- максимальная скидка — `99%`
- `promoText` максимум `30` символов
- на один `studyType` можно завести не более `3` пакетов

Тексты ошибок:

- `Скидка не может быть больше стоимости пакета`
- `Максимальная скидка — 99%`
- `Укажите сумму скидки или отключите скидку`
- `Максимум 30 символов`
- `Для одного формата можно добавить не более 3 пакетов`

## 4. Поведение item после изменения пакетов

После `create / update / delete` пакета backend автоматически:

- пересчитывает агрегированные `priceFrom` и `priceTo` у `Item`
- переводит item обратно в `DRAFT`
- ставит `isVisible = false`

Это важно для seller UI: после изменения pricing айтем нужно снова довести до актуального состояния и повторно отправить на модерацию.

## 5. Seller item detail

`GET /api/v1/seller/items/:id`

В seller item detail теперь есть:

- legacy поля `priceFrom` / `priceTo`
- новый массив `priceVariants`

Для нового шага "Настройка занятий и скидок" primary source должен быть `priceVariants`.

## 6. Catalog response

`GET /api/v1/catalog/items`

В каждом элементе каталога backend теперь возвращает дополнительные поля:

- `priceFrom`
  это минимальная итоговая цена среди всех пакетов
- `priceOriginal`
  это оригинальная цена минимального пакета до скидки, либо `null`
- `priceCurrency`
- `promoText`
  промо-текст пакета с минимальной ценой
- `priceLabel`
  label пакета с минимальной ценой

Пример:

```json
{
  "id": "uuid",
  "name": "Подготовка к ЕГЭ",
  "slug": "ege-course",
  "priceFrom": 1200000,
  "priceOriginal": 1350000,
  "priceCurrency": "UZS",
  "promoText": "Скидка до сентября",
  "priceLabel": "Персональные занятия"
}
```

Правило рендера:

- если `priceOriginal !== null`, показывать зачёркнутую старую цену
- если `promoText !== null`, показывать под ценой
- если `priceLabel !== null`, показывать подпись формата

Каталог не возвращает полный массив пакетов. Для карточки каталога нужно использовать только агрегированные поля выше.

## 7. Public item detail

`GET /api/v1/catalog/items/:slug`

В `item` теперь есть:

- `priceVariants: PriceVariantDto[]`

То есть фронт получает полную матрицу пакетов со всеми вычисленными полями и может строить детальную сетку тарифов на своей стороне.

Пример:

```json
{
  "item": {
    "id": "uuid",
    "name": "Подготовка к ЕГЭ",
    "priceVariants": [
      {
        "id": "uuid",
        "studyType": "ONE_ON_ONE",
        "lessonsCount": 5,
        "amount": 500000,
        "currency": "UZS",
        "description": "Индивидуальные занятия",
        "isHighlighted": false,
        "sortOrder": 0,
        "totalPrice": 2500000,
        "discountAmount": 136000,
        "discountPercent": 5,
        "discountedPrice": 2364000,
        "promoText": "Скидка до сентября",
        "priceLabel": "Персональные занятия"
      }
    ]
  },
  "similarItems": []
}
```

## 8. Что изменилось относительно старой модели

Backend больше не использует `special offer` как источник цены на карточке каталога и детальной страницы.

Теперь:

- скидка живёт внутри `ItemPriceVariant`
- каталог выбирает самый дешёвый пакет по итоговой цене
- item detail возвращает полную матрицу пакетов

Legacy `priceFrom` / `priceTo` у `Item` пока сохранены как агрегированные поля для:

- сортировки
- фильтрации
- совместимости текущего API

Они автоматически синхронизируются из пакетов.

## 9. Что фронту нужно сделать

1. Переключить seller pricing UI на `priceVariants`.
2. Использовать новые seller endpoints:
   - `POST /api/v1/seller/items/:id/price-variants`
   - `PATCH /api/v1/seller/items/:id/price-variants/:variantId`
   - `DELETE /api/v1/seller/items/:id/price-variants/:variantId`
3. В каталоге использовать только агрегированные поля:
   - `priceFrom`
   - `priceOriginal`
   - `priceCurrency`
   - `promoText`
   - `priceLabel`
4. На детальной странице строить тарифную матрицу из `item.priceVariants`.
5. Не ждать от backend полей в `snake_case` и не ожидать отдельного сущностного `special offer` API.

## 10. Где смотреть контракт

- OpenAPI: [openapi.json](../../apps/api/openapi/openapi.json)
- Backend handoff: [frontend-package-discounts-handoff-2026-04-15.md](./frontend-package-discounts-handoff-2026-04-15.md)
