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_name | 2–100 символов, обязательное | "Введите ваше имя" / "Имя: от 2 до 100 символов" |
| lead_phone | +998 + 9 цифр | "Введите номер в формате +998 XX XXX-XX-XX" |
| lead_email | RFC 5322, необязательное | "Введите корректный email, например: name@mail.ru" |
| lead_comment | max 500 символов, необязательное | "Комментарий не должен превышать 500 символов" |
| lead_type | trial / buy, обязательное (выбирается радиокнопкой) | — (UI не позволяет отправить без выбора) |
Бизнес-правила
- Гость может отправить лид:
buyer_account_idnullable — это не ошибка - Дубликат от залогиненного байера: проверяется при открытии модала; повторная отправка разрешена, но с предупреждением
- Дубликат от гостя: проверяется по
lead_phone + item_idза последние 24 часа — дубликат не создаётся - CPL за повторный лид: не списывается если уже есть лид с тем же
buyer_account_id + item_idза последние 30 дней - Self-test лид: если
buyer_account_idпринадлежит тому же аккаунту что и продавец — помечаетсяis_self_test = true, CPL не списывается - lead_source: фиксируется автоматически; значение
item_pageдля всех лидов из LeadModal на странице айтема - Айтем должен быть доступен:
item_isvisible = true AND moderation_status = active AND seller.account_status = active - Rate limiting: не более 5 лидов с одного IP за 10 минут (защита от спама)
- Telegram-уведомление: отправляется асинхронно — не блокирует ответ API
- Статус нового лида: всегда
newпри создании; переходы статусов управляются продавцом через CRM (→ Spec 09)
5. Модель данных
Используем существующие сущности Account, Item, Seller. Lead — новая сущность.
Lead
| Атрибут | Тип | Описание |
|---|---|---|
| lead_id | UUID | PK |
| item_id | UUID FK | → Item |
| seller_id | UUID FK | → Seller (денормализация для быстрых запросов) |
| buyer_account_id | UUID FK? | → Account, nullable (гость) |
| lead_name | string | 2–100 символов |
| lead_phone | string | +998XXXXXXXXX |
| lead_email | string? | Необязательный |
| lead_comment | string? | max 500 символов |
| lead_type | LeadType | trial / buy |
| lead_source | string | item_page / catalog / direct / api |
| lead_status | LeadStatus | new → contacted → enrolled → attended / no_show → purchased / not_purchased |
| special_offer_id | UUID FK? | → SpecialOffer, если лид создан по акции (→ Spec 02) |
| is_self_test | boolean | default: false; true если байер = продавец |
| is_duplicate_guest | boolean | default: false; служебный флаг |
| created_at | DateTime | |
| updated_at | DateTime |
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 = blocked | POST /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, реферальные ссылки) | TBD | lead_source пока принимает статическое значение. Расширить до UTM-параметров в v1.0 |
| Уведомления байеру при смене статуса лида | Исключено из MVP | Байер видит статус в /me/leads. Push/email при смене — v1.0 |
| Лид из каталога (кнопка на карточке) | TBD | lead_source = 'catalog'. Флоу идентичен, но точка входа другая — задокументировать при реализации |
| Капча / анти-спам | TBD | Rate limiting (5 лидов/IP/10мин) — минимальная защита. hCaptcha добавить в v1.0 |
| Удаление лида байером | Исключено из MVP | Байер не может удалять лиды — только архивировать (v1.0) |
| Telegram-уведомление байеру (подтверждение) | Исключено из MVP | Байер получает подтверждение только в UI. Email/Telegram — v1.0 |
| Analytics события (лид отправлен, конверсия) | TBD | SAA-слой описывается отдельно |
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-списание при создании лида; правила повторного лида |