Система пользовательского фидбека — дизайн-док
Актуально на: 2026-07-23
Статус: черновик на согласование · Дата: 2026-07-23 · Ветка: upd-0726
Документ из двух частей. Часть I — продуктовая (для созвонов и обсуждений, без кода). Часть II — техническая (модель, API, компоненты) — в конце, для реализации.
Часть I. Продукт
1. Зачем это
Единый механизм сбора обратной связи по всему приложению, чтобы понимать: удовлетворённость пользователей, находят ли они то, что искали, и где буксует опыт. Данные копятся в нашей БД и разбираются в админ-дашборде.
Зафиксированные решения:
- Зоны сбора: маркетплейс (поиск/выбор), workspace-редактор БП, пост-действия (покупка/публикация/заказ), приложение в целом.
- Форматы: «Нашли, что искали?» (да/нет), CSAT (оценка 1–5), NPS (0–10), свободный отзыв/баг-репорт. Всё через один движок.
- Навязчивость: всегда доступный пассивный виджет + умные контекстные триггеры с частотным лимитом. Без агрессивных периодических опросов.
- Хранение и разбор: своя модель в БД + админ-дашборд (лента с фильтрами, сводка метрик, модерация/статусы). Ответ пользователю на его фидбек — в бэклоге.
2. Что и где спрашиваем у пользователя
Два способа собрать отклик:
- Пассивный виджет — плавающая кнопка «Оставить отзыв», доступна всегда. Пользователь сам решает написать (свободный отзыв + категория + быстрый CSAT).
- Контекстные микро-опросы — короткая ненавязчивая карточка, всплывает в редкие значимые моменты (см. таблицу). Не модалка, легко закрыть.
Набор опросов и их точные формулировки (то, что увидит пользователь):
| Опрос | Когда показываем | Текст |
|---|---|---|
| «Нашли, что искали?» | пустая/слабая выдача поиска в маркетплейсе | «Нашли то, что искали?» → при «Нет»: «Что искали? Это поможет улучшить каталог.» |
| CSAT покупки | после успешной покупки | «Как прошло оформление покупки?» (1–5) |
| CSAT публикации | после публикации листинга | «Насколько удобно было опубликовать листинг?» (1–5) |
| CSAT редактора | значимый прогресс в редакторе БП (см. §3) | «Насколько удобно строить финмодель?» (1–5) |
| NPS | периодически (не чаще раза в 90 дней) | «Насколько вероятно, что порекомендуете PlanForge?» (0–10) |
3. Момент опроса про редактор БП
CSAT редактора спрашиваем, когда человек реально в нём поработал, но не в момент активного ввода. Считаем «значимым прогрессом» — срабатывает первое из:
- Первый экспорт/генерация БП — сигнал «дошёл до результата»;
- 3-я сессия над проектом — признак вовлечённости;
- 10+ минут активной работы за сессию — запасной сигнал.
⚠️ Экспорт финального БП — ещё недоработанная часть. Триггер подключаем неинвазивно (просто «слушаем» факт успешного экспорта, не трогая его логику), и условие №1 включаем только когда экспорт стабилизируется. До тех пор опрос редактора работает на условиях №2/№3 — чтобы не задеть WIP-функционал.
4. Что мы из этого поймём (метрики)
| Метрика | Что показывает |
|---|---|
| CSAT (сред. 1–5) | удовлетворённость по зонам (покупка, публикация, редактор) |
| NPS (−100…+100) | лояльность, готовность рекомендовать |
| Found-it rate | доля тех, кто нашёл нужное в маркетплейсе |
| Что именно не нашли | тексты запросов, по которым выдача не сработала |
| Проблемные экраны | страницы с низким CSAT / жалобами |
| Объём и бэклог | сколько откликов, сколько ещё не разобрано |
Каждая запись несёт контекст (на каком экране, какой поисковый запрос, какой лот/проект) — без этого метрика не действенна.
5. Как не раздражаем
- Не более 1 проактивного опроса за сессию и 1 раз в 3 дня на человека.
- После ответа опрос не повторяется долго (NPS — 90 дней, CSAT — 2–3 недели).
- Закрыл без ответа → надолго замолкаем; есть «Больше не показывать».
- Молчим во время активного ввода в редакторе и в процессе оплаты.
6. Этапы внедрения
Фаза 1 — MVP: пассивный сбор + админка. Виджет «Оставить отзыв» (свободный отзыв + категория + быстрый CSAT) только для авторизованных · приём и хранение · лента в админке с фильтрами + базовая сводка. Уже даёт живой поток обратной связи.
Фаза 2 — контекстные триггеры + анонимы. Умные микро-опросы («Нашли, что искали?», CSAT после покупки) · правила частоты · подключаем сбор от гостей публичного маркетплейса и антиспам для них.
Фаза 3 — NPS + полная аналитика. Периодический NPS · опросы публикации и редактора · метрики и тренды в дашборде, топ проблемных экранов · теги/приоритеты.
Фаза 4 — бэклог: замыкание петли. Ответ автору фидбека (уведомление/email), шаблоны реакций. Сейчас не берём.
7. Решения и открытые вопросы
Решено:
- Анонимы — сбор от гостей маркетплейса откладываем в Фазу 2; MVP собирает только у авторизованных.
- Антиспам — решаем вместе с подключением анонимов (Фаза 2), не заранее.
- Момент опроса редактора — первый экспорт → 3-я сессия → 10+ минут (см. §3).
Открыто:
- Тексты опросов (§2) — согласовать формулировки.
Часть II. Техника
8. Модель данных (Prisma)
Две модели: FeedbackEntry (сам ответ) и FeedbackPromptEvent (журнал показов —
основа частотного лимита, в т.ч. для анонимов). Стиль как в проекте: cuid(),
enum’ы, индексы, onDelete: SetNull/Cascade.
model FeedbackEntry {
id String @id @default(cuid())
// Что за отклик
type FeedbackType // FOUND_IT | CSAT | NPS | FREEFORM
zone FeedbackZone // MARKETPLACE | WORKSPACE | POST_ACTION | APP_GLOBAL
surveyKey String? // ключ конкретного опроса из конфига движка
source FeedbackSource @default(WIDGET) // WIDGET (сам открыл) | TRIGGER (контекст) | SURVEY (периодич.)
// Ответ (унифицированный)
rating Int? // FOUND_IT: 1/0 · CSAT: 1..5 · NPS: 0..10
category FeedbackCategory? // для FREEFORM: IDEA | PROBLEM | QUESTION | BUG
comment String?
// Контекст
route String? // pathname, где оставлен отклик
context Json? // { searchQuery, listingId, projectId, resultsCount, ... }
// Кто (авторизованный ИЛИ аноним)
userId String?
anonymousId String? // first-party cookie pf_fb_anon для гостей
userAgent String?
locale String?
// Модерация
status FeedbackStatus @default(NEW) // NEW | IN_PROGRESS | RESOLVED | SPAM
priority FeedbackPriority @default(NORMAL) // LOW | NORMAL | HIGH
tags String[] @default([])
adminNote String?
handledById String?
handledAt DateTime?
createdAt DateTime @default(now())
user User? @relation("FeedbackAuthor", fields: [userId], references: [id], onDelete: SetNull)
handledBy User? @relation("FeedbackHandler", fields: [handledById], references: [id], onDelete: SetNull)
@@index([zone, type, createdAt])
@@index([status, createdAt])
@@index([userId])
@@index([anonymousId])
}
model FeedbackPromptEvent {
id String @id @default(cuid())
surveyKey String // какой опрос
action FeedbackPromptAction // SHOWN | DISMISSED | ANSWERED | SNOOZED
userId String?
anonymousId String?
route String?
createdAt DateTime @default(now())
@@index([surveyKey, userId, createdAt])
@@index([surveyKey, anonymousId, createdAt])
}
enum FeedbackType { FOUND_IT CSAT NPS FREEFORM }
enum FeedbackZone { MARKETPLACE WORKSPACE POST_ACTION APP_GLOBAL }
enum FeedbackSource { WIDGET TRIGGER SURVEY }
enum FeedbackCategory { IDEA PROBLEM QUESTION BUG }
enum FeedbackStatus { NEW IN_PROGRESS RESOLVED SPAM }
enum FeedbackPriority { LOW NORMAL HIGH }
enum FeedbackPromptAction { SHOWN DISMISSED ANSWERED SNOOZED }На стороне User добавляются обратные связи feedbackEntries / handledFeedback.
rating Int? вместо отдельных колонок: один числовой столбец легко агрегировать,
семантику задаёт type. Хелперы isPromoter/isDetractor (NPS) и isPositive
(CSAT/FOUND_IT) — в lib/feedback.ts.
Миграции: по правилам проекта только
prisma generate, синхронизацию БД оставляем пользователю. Готовлю SQL-файл в стилеadd-*.sql(add-feedback.sql).
9. Движок форматов (конфиг опросов)
Опросы описаны конфигом в коде (не в БД) — один источник правды для текста, условий показа и лимитов.
// lib/feedback/surveys.ts
export const SURVEYS: SurveyConfig[] = [
{
key: "mkt.found_it",
type: "FOUND_IT", zone: "MARKETPLACE", source: "TRIGGER",
question: "Нашли то, что искали?",
followupOnNo: "Что искали? Это поможет нам улучшить каталог.",
trigger: "search:empty", // пустая/нерелевантная выдача
cooldownDays: 7, maxPerWeek: 1,
},
{
key: "post.purchase_csat",
type: "CSAT", zone: "POST_ACTION", source: "TRIGGER",
question: "Как прошло оформление покупки?",
trigger: "purchase:success",
cooldownDays: 14,
},
{
key: "post.publish_csat",
type: "CSAT", zone: "POST_ACTION", source: "TRIGGER",
question: "Насколько удобно было опубликовать листинг?",
trigger: "listing:published",
cooldownDays: 14,
},
{
key: "workspace.editor_csat",
type: "CSAT", zone: "WORKSPACE", source: "TRIGGER",
question: "Насколько удобно строить финмодель?",
trigger: "editor:milestone", // см. §10 — набор условий, срабатывает первое
cooldownDays: 21, quietDuringInput: true,
},
{
key: "app.nps",
type: "NPS", zone: "APP_GLOBAL", source: "SURVEY",
question: "Насколько вероятно, что порекомендуете PlanForge?",
trigger: "app:periodic",
cooldownDays: 90, minAccountAgeDays: 7,
},
];Пассивный виджет (свободный отзыв + быстрый CSAT) не входит в конфиг триггеров — он доступен всегда по кнопке.
10. Триггеры и частотный лимит (governance)
Триггеры — доменные события, которые слушает провайдер фидбека:
-
search:empty— маркетплейс вернул 0/мало релевантных результатов. -
purchase:success— экран успешной покупки. -
listing:published— листинг ушёл на модерацию/опубликован. -
editor:milestone— значимый прогресс в редакторе БП. Срабатывает первое из условий (по приоритету), с учётом cooldown и «тихих зон»:- Первый экспорт/генерация БП (PDF/план-док).
- N-я сессия над проектом (напр. 3-я).
- N минут активной работы за сессию (напр. 10+), в паузе ввода.
⚠️ Экспорт финального БП — недоработанная часть. Подключаем неинвазивно: только
feedbackBus.emit('editor:milestone', {reason:'export'})в точке успешного экспорта, без вмешательства в логику генерации. Условие №1 включаем лишь когда экспорт стабилизируется; до тех пор — условия №2/№3. -
app:periodic— фоновая проверка на NPS.
Глобальные правила (движок применяет поверх конфига):
- Не более 1 проактивного промпта за сессию.
- Не более 1 проактивного промпта раз в 3 дня на пользователя/анонима.
- Cooldown после ответа =
cooldownDaysопроса. - Dismiss эскалирует suppression: 1-й отказ → 30д; 2-й → 90д; есть «Больше не показывать».
- Тихие зоны: активный ввод в редакторе и процесс checkout.
- Показ решается на сервере (
GET /api/feedback/eligibility) — правила централизованы и работают для анонимов (журналFeedbackPromptEvent); плюс лёгкий клиентский guard вlocalStorage.
Аноним идентифицируется first-party cookie pf_fb_anon (uuid, ставит провайдер).
11. UX-компоненты
Пассивный виджет (всегда): Контекстный микро-опрос (триггер):
┌───────────────────────────┐ ┌───────────────────────────────┐
│ … контент страницы … │ │ Нашли то, что искали? │
│ │ │ [ Да ] [ Нет ] ✕ │
│ ╭────╮ │ └───────────────────────────────┘
│ │ 💬 │ │ (ненавязчивая карточка снизу,
│ ╰────╯ │ НЕ модалка; при «Нет» —
└───────────────────────────┘ раскрывается поле комментария)
Раскрытый виджет:
┌───────────────────────────┐
│ Поделитесь мнением ✕ │
│ ○ Идея ○ Проблема ○ Баг │
│ ☆☆☆☆☆ (быстрый CSAT) │
│ ┌───────────────────────┐│
│ │ Ваш комментарий… ││
│ └───────────────────────┘│
│ [ Отправить ] │
└───────────────────────────┘Компоненты (components/feedback/):
FeedbackProvider— монтируется в корневом/app-layout. Держит cookie анонима, слушает события-триггеры (шинаfeedbackBus.emit(...)), спрашиваетeligibility, рендерит промпты, шлётprompt-event.FeedbackWidget— плавающая кнопка + панель (свободный отзыв, категория, быстрый CSAT). Скрывается в тихих зонах.MicroSurvey— компактная нижняя карточка (Да/Нет или ряд звёзд) с опциональным follow-up. Не блокирует интерфейс, авто-скрытие по таймауту.
Точки эмита триггеров (интеграция в существующий код):
- Маркетплейс: выдача при
resultsCount === 0→emit('search:empty', { query }). - Оплата: страница
payment/послеorder→purchase:success. - Публикация: после сабмита листинга (
my-listings) →listing:published. - Редактор: workspace, при первом экспорте/сессии/таймере →
editor:milestone.
12. API
Все ручки — в стиле проекта (getApiUser, json, unauthorized, forbidden,
isAdmin из lib/), с rate-limit и валидацией по type.
Публичные / пользовательские
POST /api/feedback— приём отклика. Auth опционален (гость →anonymousId). Валидируетratingпо диапазону типа, режет длинуcomment, антиспам (лимит N/час на аноним+IP). ПишетFeedbackEntry+FeedbackPromptEvent(ANSWERED). ОпциональноtrackEvent('feedback_submitted', {...})для воронок OpenPanel.GET /api/feedback/eligibility?zone=&route=— сервер решает, какой опрос (если вообще) показать, применяя правила частоты. ВозвращаетsurveyKeyилиnull.POST /api/feedback/prompt-event— журналированиеSHOWN | DISMISSED | SNOOZED.
Админские (/api/admin/feedback, только isAdmin)
GET /api/admin/feedback— лента: пагинация + фильтры (zone,type,status,ratingMin/Max,hasComment,dateFrom/To, полнотекст поcomment).GET /api/admin/feedback/summary?period=— метрики: CSAT avg, NPS, found-it rate по зонам, объём, бэклог, топ проблемныхroute, тренды.PATCH /api/admin/feedback/[id]— модерация:status,priority,tags,adminNote.
13. Админ-дашборд
Новая вкладка в app/(app)/admin/layout.tsx (массив TABS):
{ href: "/admin/feedback", label: "Фидбек", icon: IconMessage }.
Страница /admin/feedback (в стиле остальных admin-разделов):
- Карточки сводки: CSAT (за период), NPS, Found-it rate, новых/в работе.
- Тренды: спарклайны CSAT и NPS; доля «не нашёл» по зонам.
- Топ проблемных экранов:
routeс низким средним рейтингом/жалобами. - Лента: таблица с фильтрами (зона, тип, оценка, статус, дата, есть коммент,
поиск по тексту). Строка → детальная панель с полным
context. - Модерация: статус (NEW/IN_PROGRESS/RESOLVED/SPAM), приоритет, теги, заметка.
14. Приватность и правовое
- От анонимов не собираем PII;
comment— свободный текст, предупреждаем не вводить личные данные. - Учитываем систему согласий (
LegalConsent/ConsentGate): строка про сбор обратной связи в политику; cookiepf_fb_anon— функциональный, отметить в cookie-политике. - При удалении аккаунта
userId→SetNull(отклик остаётся обезличенным).