Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-07-lead-submission.md

MVP Spec 07 — Lead Submission (Buyer)

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев

MVP Spec 07 — Lead Submission (Buyer)

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

  • Статус документа: 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. Контекст и цель

Lead Submission — ключевая точка монетизации платформы. Каждый лид — это заявка потенциального ученика (или его родителя) на пробный урок или запись на курс. Именно лиды являются основным продуктом, который Qadam продаёт продавцам.

Цель модуля: обеспечить максимально низкий порог отправки заявки — гость должен оставить контакт в 3 клика, не требуя регистрации. Одновременно залогиненный байер должен получить ещё более быстрый опыт с предзаполненными полями.

Ключевые продуктовые решения:

  • Гость (незарегистрированный пользователь) МОЖЕТ отправить лид — регистрация не требуется
  • Если байер залогинен: имя и телефон предзаполнены из профиля
  • Два типа лида: trial (пробный урок) и buy (запись на курс)
  • После отправки: подтверждение с контактами продавца или «мы вам перезвоним»
  • Лид привязывается к конкретному айтему И продавцу
  • Каждый лид = 1 CPL-списание с баланса продавца (биллинг → Spec 16)
  • Продавец получает уведомление через Telegram (→ Spec 10)

Что не входит в этот модуль:

  • Статусная модель лида со стороны продавца (CRM) → Spec 09
  • Telegram-уведомление продавцу → Spec 10
  • Биллинг (CPL-списания) → Spec 16
  • Публичная карточка айтема /item/[slug] → Spec 06
  • Отзывы и рейтинги → Spec 11

2. Роли пользователей

РольДействие в этом модуле
ГостьОткрывает LeadModal, заполняет имя/телефон вручную, отправляет лид
Buyer (залогиненный)Открывает LeadModal с предзаполненными полями, подтверждает или редактирует, отправляет лид
Seller / Seller StaffНе могут отправлять лиды на собственные айтемы (блокируется)
AdminВидит все лиды в /admin, может менять статусы

3. Use Cases


UC-01: Гость отправляет лид (Happy Path)

Актор: Гость (незарегистрированный пользователь) Предусловие: Айтем опубликован (moderation_status = active, item_isvisible = true) Триггер: Гость хочет записаться на курс или попробовать пробный урок

Полный поток:

[Точка входа]
→ Пользователь находится на странице /item/[slug]
→ Видит кнопку "Записаться" (CTA, всегда видна на странице айтема)
→ Нажимает "Записаться"
→ Открывается LeadModal (модальное окно поверх страницы)

───────────────────────────────────────────────────────
LEAD MODAL — Структура и поля
───────────────────────────────────────────────────────
→ Заголовок: "Записаться на курс" (название айтема под заголовком, max 60 символов, обрезается с ...)
→ Переключатель типа заявки (обязательный, radio):
    ⦿ Пробный урок    ○ Записаться (полный курс)
  По умолчанию: Пробный урок (trial)
→ Поля:
    - Ваше имя *  (placeholder: "Иван Иванов")
    - Номер телефона * (placeholder: "+998 XX XXX-XX-XX", маска при вводе)
    - Email (необязательно, placeholder: "для подтверждения записи")
    - Комментарий (необязательно, placeholder: "Возраст ребёнка, пожелания по времени...")
      textarea, max 500 символов, счётчик символов
→ Внизу: кнопка "Отправить заявку"
→ Дисклеймер под кнопкой: "Нажимая кнопку, вы соглашаетесь с обработкой персональных данных"

───────────────────────────────────────────────────────
ОТПРАВКА
───────────────────────────────────────────────────────
→ Пользователь заполняет имя и телефон
→ Нажимает "Отправить заявку"
→ Кнопка переходит в состояние loading: spinner + "Отправляем..."
→ Система:
    1. Создаёт Lead {
         item_id: текущий айтем,
         seller_id: продавец айтема,
         buyer_account_id: null (гость),
         lead_name, lead_phone, lead_email, lead_comment,
         lead_type: trial | buy,
         lead_source: 'item_page',
         lead_status: 'new'
       }
    2. Запускает фоновую задачу: уведомление продавца в Telegram (→ Spec 10)
    3. Запускает фоновую задачу: CPL-списание с продавца (→ Spec 16)
→ Modal переключается в состояние SUCCESS

───────────────────────────────────────────────────────
ЭКРАН УСПЕХА (внутри того же модала)
───────────────────────────────────────────────────────
→ Иконка ✓ (зелёная)
→ Заголовок: "Заявка отправлена!"
→ Текст: "Менеджер {org_name} свяжется с вами в течение рабочего дня."
→ Если у продавца есть phone: показываем "Или позвоните сами: {contact_phone}"
→ Кнопка: "Закрыть"
→ При закрытии: модал закрывается, пользователь остаётся на странице /item/[slug]

UC-01 — Альтернативные потоки и обработка ошибок

Ошибки валидации полей

1a. Поле "Имя" пустое при нажатии "Отправить":

UI-реакция:
→ Поле имени: красная обводка + красный значок ⚠ справа
→ Под полем: "Введите ваше имя"
→ Кнопка остаётся в нормальном состоянии (не loading)
→ Фокус переходит на поле имени

1b. Поле "Телефон" пустое или неверный формат:

UI-реакция:
→ Поле телефона: красная обводка + ⚠
→ Под полем (если пустое): "Введите номер телефона"
→ Под полем (если неверный формат): "Введите номер в формате +998 XX XXX-XX-XX"
→ Маска при вводе автоматически форматирует: +998 (__)___-__-__

1c. Email введён, но неверный формат (on-blur валидация):

UI-реакция:
→ Поле email: красная обводка + ⚠
→ Под полем: "Введите корректный email, например: name@mail.ru"
→ Кнопка "Отправить заявку" остаётся заблокированной до исправления

1d. Комментарий превысил 500 символов:

UI-реакция:
→ Счётчик под textarea: красный "507/500"
→ Поле textarea: красная обводка
→ Под полем: "Комментарий не должен превышать 500 символов"
→ Кнопка "Отправить заявку" недоступна

Серверные ошибки

1e. Технический сбой (сеть/сервер недоступен):

UI-реакция:
→ Кнопка выходит из состояния loading
→ Toast (красный) под модалом: "Не удалось отправить заявку. Проверьте интернет-соединение и попробуйте снова."
→ Все поля формы остаются заполненными — данные не сбрасываются
→ Кнопка снова активна
→ Если ошибка повторяется 2+ раз: добавляется "Написать напрямую: {contact_phone продавца}"

1f. Сервер вернул 500 (неожиданная ошибка):

UI-реакция:
→ Toast: "Что-то пошло не так. Попробуйте ещё раз или напишите нам: support@qadam.uz"
→ В консоль (dev): оригинальное сообщение ошибки
→ Данные формы не сбрасываются

UC-02: Залогиненный байер отправляет лид (поля предзаполнены)

Актор: Buyer (авторизован, account_type = BUYER) Предусловие: Байер авторизован через cookie, айтем опубликован Триггер: Байер нажимает "Записаться" на странице айтема

Полный поток:

[Точка входа]
→ Байер находится на /item/[slug], авторизован (httpOnly cookie присутствует)
→ Нажимает "Записаться"
→ Открывается LeadModal

───────────────────────────────────────────────────────
ПРЕДЗАПОЛНЕНИЕ ПОЛЕЙ
───────────────────────────────────────────────────────
→ Система читает профиль байера (GET /api/v1/me/profile)
→ Поле "Ваше имя": предзаполнено "{first_name} {last_name}" из профиля Buyer
→ Поле "Номер телефона": предзаполнено из Account.phone
→ Поле "Email": предзаполнено из Account.email (если есть)
→ Рядом с предзаполненными полями: серый текст "из вашего профиля" (мелкий)
→ Все предзаполненные поля редактируемы — байер может изменить для этой конкретной заявки

───────────────────────────────────────────────────────
ВЫБОР ТИПА И ОТПРАВКА
───────────────────────────────────────────────────────
→ Байер выбирает тип заявки (trial / buy)
→ При необходимости редактирует или добавляет комментарий
→ Нажимает "Отправить заявку"
→ Система создаёт Lead с buyer_account_id = {текущий account_id}
→ Экран успеха — аналогично UC-01

───────────────────────────────────────────────────────
ОТЛИЧИЕ ОТ ГОСТЯ
───────────────────────────────────────────────────────
→ Лид сохраняется с buyer_account_id (не null)
→ Лид отображается в /me/leads байера
→ На странице успеха: дополнительная ссылка "Посмотреть мои заявки" (→ /me/leads)

UC-02 — Альтернативные потоки

2a. Профиль байера загружается с задержкой (медленная сеть):

UI-реакция:
→ Поля имя/телефон показывают skeleton-loader (серые плейсхолдеры)
→ Кнопка "Отправить заявку" disabled пока данные загружаются
→ Если загрузка > 3 секунд: поля становятся пустыми и редактируемыми
→ Toast (жёлтый): "Не удалось загрузить данные профиля. Заполните вручную."

2b. Байер пытается отправить лид на айтем своего же аккаунта (edge case):

Поведение:
→ Это теоретически невозможно (у байера account_type = BUYER, айтемы создаёт SELLER)
→ Если один человек зарегистрирован и как байер и как продавец (multi-account):
    — Лид НЕ блокируется (продавец может тестировать свой флоу)
    — В системе создаётся лид с пометкой is_self_test: true (не учитывается в биллинге)
    — Продавец не получает уведомление в Telegram для self-test лидов
    → Внутри LeadModal: баннер (жёлтый) "Это ваш собственный курс. Заявка будет помечена как тест."

UC-03: Байер повторно оставляет заявку на тот же айтем (дубликат)

Актор: Buyer (залогиненный) или Гость (с тем же телефоном) Предусловие: Лид с таким buyer_account_id + item_id или phone + item_id уже существует Триггер: Нажал "Записаться" повторно на том же айтеме

Полный поток:

[Точка входа]
→ Байер или гость открывает /item/[slug], нажимает "Записаться"
→ Открывается LeadModal

───────────────────────────────────────────────────────
ПРОВЕРКА ДУБЛИКАТА (для залогиненного байера)
───────────────────────────────────────────────────────
→ При открытии модала: GET /api/leads/check?item_id={item_id}
    — если существует лид с lead_status IN (new, contacted, enrolled):
      → Показываем баннер (синий/информационный) в верхней части модала:
        "Вы уже оставляли заявку на этот курс. Статус: {human_readable_status}.
         Отправить ещё одну заявку?"
      → Кнопка "Отправить ещё одну" (продолжает нормальный флоу)
      → Ссылка "Посмотреть мою заявку" → /me/leads
    — если нет активных лидов: нормальный флоу без баннера

───────────────────────────────────────────────────────
ОТПРАВКА ПОВТОРНОГО ЛИДА
───────────────────────────────────────────────────────
→ Байер нажимает "Отправить ещё одну"
→ Создаётся новый лид (повторный лид разрешён)
→ В Lead.lead_comment автоматически добавляется пометка: "[повторная заявка]"
→ CPL НЕ списывается за повторный лид на тот же айтем в течение 30 дней (→ Spec 16)
→ Экран успеха — стандартный

3a. Гость повторно оставляет заявку с тем же телефоном:

Поведение (серверная проверка при POST /api/leads):
→ Сервер проверяет: EXISTS(lead WHERE lead_phone = X AND item_id = Y AND created_at > now() - 24h)
→ Если дубликат обнаружен в течение 24 часов:
    → 200 (не ошибка!) с флагом { duplicate: true }
    → Фронтенд показывает экран успеха с текстом:
      "Заявка уже была отправлена. Менеджер {org_name} свяжется с вами в ближайшее время."
→ Дубликат не создаётся в БД, CPL не списывается

UC-04: Айтем стал недоступен во время заполнения модала (race condition)

Актор: Любой пользователь (гость или байер) Предусловие: Пользователь открыл LeadModal, затем айтем был снят с публикации/заблокирован Триггер: Нажимает "Отправить заявку" — сервер возвращает ошибку

Полный поток:

[Сценарий]
→ Пользователь открыл страницу /item/[slug], айтем был виден
→ Открыл LeadModal, заполняет поля
→ В это время: продавец снял айтем с публикации (item_isvisible = false)
  ИЛИ: модератор отклонил айтем (moderation_status = rejected)
  ИЛИ: аккаунт продавца заблокирован
→ Пользователь нажимает "Отправить заявку"
→ Сервер отклоняет с 410 ITEM_UNAVAILABLE

UI-реакция:
→ Модал не закрывается
→ Над кнопкой появляется блок ошибки (красный):
    ⚠ "Этот курс больше не доступен для записи."
    "Возможно, набор завершён или курс временно приостановлен."
→ Кнопка "Отправить заявку" заменяется кнопкой "Смотреть похожие курсы"
    → нажатие закрывает модал и переводит на /catalog с предустановленным фильтром
      по subject_id айтема
→ Данные введённые пользователем НЕ используются — лид не создаётся

4. Бизнес-правила и валидации

Таблица валидаций полей

ПолеПравилоОшибка пользователю
lead_name2–100 символов, обязательное"Введите ваше имя" / "Имя: от 2 до 100 символов"
lead_phone+998 + 9 цифр"Введите номер в формате +998 XX XXX-XX-XX"
lead_emailRFC 5322, необязательное"Введите корректный email, например: name@mail.ru"
lead_commentmax 500 символов, необязательное"Комментарий не должен превышать 500 символов"
lead_typetrial / buy, обязательное (выбирается радиокнопкой)— (UI не позволяет отправить без выбора)

Бизнес-правила

  1. Гость может отправить лид: buyer_account_id nullable — это не ошибка
  2. Дубликат от залогиненного байера: проверяется при открытии модала; повторная отправка разрешена, но с предупреждением
  3. Дубликат от гостя: проверяется по lead_phone + item_id за последние 24 часа — дубликат не создаётся
  4. CPL за повторный лид: не списывается если уже есть лид с тем же buyer_account_id + item_id за последние 30 дней
  5. Self-test лид: если buyer_account_id принадлежит тому же аккаунту что и продавец — помечается is_self_test = true, CPL не списывается
  6. lead_source: фиксируется автоматически; значение item_page для всех лидов из LeadModal на странице айтема
  7. Айтем должен быть доступен: item_isvisible = true AND moderation_status = active AND seller.account_status = active
  8. Rate limiting: не более 5 лидов с одного IP за 10 минут (защита от спама)
  9. Telegram-уведомление: отправляется асинхронно — не блокирует ответ API
  10. Статус нового лида: всегда new при создании; переходы статусов управляются продавцом через CRM (→ Spec 09)

5. Модель данных

Используем существующие сущности Account, Item, Seller. Lead — новая сущность.

Lead

АтрибутТипОписание
lead_idUUIDPK
item_idUUID FK→ Item
seller_idUUID FK→ Seller (денормализация для быстрых запросов)
buyer_account_idUUID FK?→ Account, nullable (гость)
lead_namestring2–100 символов
lead_phonestring+998XXXXXXXXX
lead_emailstring?Необязательный
lead_commentstring?max 500 символов
lead_typeLeadTypetrial / buy
lead_sourcestringitem_page / catalog / direct / api
lead_statusLeadStatusnew → contacted → enrolled → attended / no_show → purchased / not_purchased
special_offer_idUUID FK?→ SpecialOffer, если лид создан по акции (→ Spec 02)
is_self_testbooleandefault: false; true если байер = продавец
is_duplicate_guestbooleandefault: false; служебный флаг
created_atDateTime
updated_atDateTime

LeadStatus (enum)

СтатусКто устанавливаетЧто видит байер в /me/leads
newСистема (при создании)"Новая"
contactedПродавец (CRM)"Продавец связался"
enrolledПродавец"Вы записаны"
attendedПродавец"Посещено"
no_showПродавец"Не пришли"
purchasedПродавец"Куплено"
not_purchasedПродавец"Не купили"

6. Технические контракты

6.1 Prisma Schema

enum LeadType {
  trial
  buy
}

enum LeadStatus {
  new               // только что создан
  contacted         // продавец связался с байером
  enrolled          // клиент записан на занятие
  attended          // пришёл на пробное занятие
  no_show           // не пришёл на пробное занятие
  purchased         // оплатил / оформил покупку
  not_purchased     // решил не покупать
  rejected          // продавец отклонил лид
}

model Lead {
  lead_id           String      @id @default(uuid())
  item_id           String
  seller_id         String
  buyer_account_id  String?
  lead_name         String      @db.VarChar(100)
  lead_phone        String
  lead_email        String?
  lead_comment      String?     @db.VarChar(500)
  lead_type         LeadType
  lead_source       String      @db.VarChar(50)
  lead_status       LeadStatus  @default(new)
  special_offer_id  String?
  is_self_test      Boolean     @default(false)
  is_duplicate_guest Boolean    @default(false)
  created_at        DateTime    @default(now())
  updated_at        DateTime    @updatedAt

  item          Item     @relation(fields: [item_id], references: [item_id])
  seller        Seller   @relation(fields: [seller_id], references: [seller_id])
  buyer_account Account? @relation(fields: [buyer_account_id], references: [account_id])

  @@index([item_id])
  @@index([seller_id])
  @@index([buyer_account_id])
  @@index([lead_phone, item_id])  // для проверки дубликатов гостей
}

6.2 TypeScript DTO

// ─── Создание лида ────────────────────────────────────────────────────────

export class CreateLeadDto {
  @IsUUID()
  item_id: string

  @IsString() @MinLength(2) @MaxLength(100)
  lead_name: string

  @Matches(/^\+998\d{9}$/, { message: 'Введите номер в формате +998XXXXXXXXX' })
  lead_phone: string

  @IsOptional()
  @IsEmail({}, { message: 'Введите корректный email' })
  lead_email?: string

  @IsOptional() @IsString() @MaxLength(500)
  lead_comment?: string

  @IsEnum(LeadType)
  lead_type: LeadType

  @IsOptional()
  @IsUUID()
  special_offer_id?: string  // id акции если лид создан по акции
}

// ─── Проверка дубликата (для залогиненного байера) ────────────────────────

export interface LeadDuplicateCheckResponse {
  has_active_lead: boolean
  lead_status?: LeadStatus
  lead_id?: string
}

// ─── Ответ после создания лида ────────────────────────────────────────────

export interface CreateLeadResponse {
  lead_id: string
  duplicate: boolean          // true если гость-дубликат (24ч)
  seller_contact_phone: string | null
  seller_org_name: string
  message: string             // human-readable подтверждение
}

// ─── Список лидов байера (GET /api/v1/me/leads) ───────────────────────────

export interface BuyerLeadListItemDto {
  lead_id: string
  lead_type: LeadType
  lead_status: LeadStatus
  lead_status_label: string   // human-readable: "Продавец связался"
  item_id: string
  item_name: string
  item_slug: string
  seller_org_name: string
  created_at: string          // ISO 8601
}

6.3 API Endpoints

────────────────────────────────────────────────────────────────
СОЗДАНИЕ ЛИДА (публичный endpoint, авторизация не требуется)
────────────────────────────────────────────────────────────────

POST /api/leads
Auth: нет (но если cookie присутствует — читаем buyer_account_id)
Body: CreateLeadDto
→ 201: CreateLeadResponse
→ 410: { error: 'ITEM_UNAVAILABLE', message: 'Этот курс больше не доступен для записи.' }
→ 422: { errors: [{ field: string, message: string }] }
→ 429: { error: 'RATE_LIMIT', message: 'Слишком много заявок. Попробуйте через {retry_after} секунд.' }
→ 500: { error: 'INTERNAL_ERROR', message: 'Что-то пошло не так. Попробуйте позже.' }

────────────────────────────────────────────────────────────────
ПРОВЕРКА ДУБЛИКАТА (для залогиненного байера)
────────────────────────────────────────────────────────────────

GET /api/leads/check?item_id={item_id}
Auth: Bearer (buyer) — опциональный
→ 200: LeadDuplicateCheckResponse
→ 401: { error: 'UNAUTHORIZED' }  // если пытаются обойти авторизацию

────────────────────────────────────────────────────────────────
ЛИДЫ БАЙЕРА (личный кабинет)
────────────────────────────────────────────────────────────────

GET /api/v1/me/leads
Auth: Bearer (buyer)
Query params: ?page=1&limit=20&status=new,contacted (опционально)
→ 200: {
    data: BuyerLeadListItemDto[],
    pagination: { page: number, limit: number, total: number, total_pages: number }
  }
→ 401: { error: 'UNAUTHORIZED' }

GET /api/v1/me/leads/:lead_id
Auth: Bearer (buyer)
→ 200: BuyerLeadListItemDto & { lead_comment: string | null }
→ 401: { error: 'UNAUTHORIZED' }
→ 403: { error: 'FORBIDDEN' }  // лид принадлежит другому байеру
→ 404: { error: 'LEAD_NOT_FOUND' }

7. Edge Cases и обработка ошибок

СценарийПоведение
Гость отправляет лид с телефоном уже зарегистрированного байераЛид создаётся с buyer_account_id = null. Позже при логине байер не видит эти лиды — они не связываются ретроспективно (MVP ограничение)
Айтем принадлежит продавцу с account_status = blockedPOST /api/leads → 410 ITEM_UNAVAILABLE (проверяем статус продавца при создании лида)
Телефон передан без маски (например: "998901234567")Backend нормализует: добавляет "+" если нет. Если не соответствует после нормализации — 422
Параллельные запросы создания лида (double submit)Идемпотентность на фронте: кнопка блокируется после первого нажатия. На бэке: уникальный индекс не предусмотрен (повторная отправка разрешена с задержкой)
Продавец не подключил Telegram (telegram_chat_id = null)Лид создаётся нормально. Уведомление не отправляется. В CRM продавца лид появляется. Toast не показывается байеру — это внутренняя проблема продавца
special_offer_id передан, но акция истеклаBackend проверяет expires_at > now(). Если истекла: лид создаётся без special_offer_id, без ошибки (не блокируем отправку)
Пользователь закрывает модал в состоянии loadingЗапрос не отменяется (fire-and-forget). Если успех придёт — можно игнорировать. Лид создаётся, но confirmtion не показывается
Браузер offline при нажатии "Отправить заявку"fetch() упадёт с TypeError. Обрабатываем как сетевую ошибку (1e)

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

ТемаСтатусПримечание
Ретроспективная привязка гостевых лидов к байеру при логинеИсключено из MVPСлишком сложно для MVP: нужно матчить по телефону при каждом логине. Добавить в v1.0
Lead attribution (UTM, реферальные ссылки)TBDlead_source пока принимает статическое значение. Расширить до UTM-параметров в v1.0
Уведомления байеру при смене статуса лидаИсключено из MVPБайер видит статус в /me/leads. Push/email при смене — v1.0
Лид из каталога (кнопка на карточке)TBDlead_source = 'catalog'. Флоу идентичен, но точка входа другая — задокументировать при реализации
Капча / анти-спамTBDRate limiting (5 лидов/IP/10мин) — минимальная защита. hCaptcha добавить в v1.0
Удаление лида байеромИсключено из MVPБайер не может удалять лиды — только архивировать (v1.0)
Telegram-уведомление байеру (подтверждение)Исключено из MVPБайер получает подтверждение только в UI. Email/Telegram — v1.0
Analytics события (лид отправлен, конверсия)TBDSAA-слой описывается отдельно

9. Зависимости

МодульСвязь
Spec 01 (Seller Onboarding)Lead.seller_id → Seller; проверяем seller.account_status при создании лида
Spec 02 (Item Management)Lead.item_id → Item; проверяем item_isvisible + moderation_status
Spec 06 (Public Item Page)Кнопка "Записаться" и LeadModal рендерятся на /item/[slug]
Spec 08 (Buyer Onboarding)Lead.buyer_account_id → Account; предзаполнение полей из профиля байера
Spec 09 (Seller CRM)Продавец управляет статусами лидов через /seller/leads
Spec 10 (Notifications)Telegram-уведомление продавцу при новом лиде
Spec 16 (Billing / CPL)CPL-списание при создании лида; правила повторного лида