Реализация NPS-опроса на сайте
NPS (Net Promoter Score) — стандарт измерения лояльности: «Насколько вероятно, что вы порекомендуете нас друзьям?» по шкале 0–10. Промоутеры (9–10), нейтральные (7–8), критики (0–6). NPS = % промоутеров − % критиков.
Когда и кому показывать
Время показа влияет на качество данных:
- После ключевого события: завершение заказа, окончание пробного периода, первое успешное использование фичи
- По времени: через 14–30 дней после регистрации, не чаще раза в 90 дней на пользователя
- Сегментирование: не показывать новым пользователям (< 7 дней), исключить пользователей с открытыми тикетами поддержки
Backend: модель и API
// database/migrations/create_nps_responses_table.php
Schema::create('nps_responses', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('session_id')->nullable();
$table->tinyInteger('score')->unsigned(); // 0-10
$table->text('comment')->nullable();
$table->string('trigger_event')->nullable(); // 'order_completed', 'trial_ended'
$table->string('page_url')->nullable();
$table->ipAddress('ip')->nullable();
$table->timestamps();
});
// NpsController
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'score' => 'required|integer|min:0|max:10',
'comment' => 'nullable|string|max:1000',
'trigger_event' => 'nullable|string|max:100',
]);
$response = NpsResponse::create([
...$validated,
'user_id' => auth()->id(),
'session_id' => $request->session()->getId(),
'page_url' => $request->input('page_url'),
'ip' => $request->ip(),
]);
// Сохраняем в сессии чтобы не показывать снова
$request->session()->put('nps_submitted_at', now()->timestamp);
// Алерт в Slack если критик оставил комментарий
if ($validated['score'] <= 6 && !empty($validated['comment'])) {
SlackNotification::send('#feedback', "NPS {$validated['score']}: {$validated['comment']}");
}
return response()->json(['success' => true]);
}
Условие показа виджета
// Middleware или хелпер для проверки
public function shouldShowNps(Request $request): bool
{
$user = auth()->user();
if (!$user) return false;
// Не показывать чаще раза в 90 дней
$lastShown = $request->session()->get('nps_shown_at');
if ($lastShown && now()->timestamp - $lastShown < 90 * 86400) {
return false;
}
// Уже ответил
if ($request->session()->has('nps_submitted_at')) return false;
// Пользователь зарегистрирован > 14 дней
return $user->created_at->diffInDays(now()) >= 14;
}
Frontend: виджет
// NpsWidget.tsx
export function NpsWidget({ triggerEvent }: { triggerEvent: string }) {
const [score, setScore] = useState<number | null>(null);
const [comment, setComment] = useState('');
const [step, setStep] = useState<'score' | 'comment' | 'done'>('score');
const submit = async () => {
await fetch('/api/nps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ score, comment, trigger_event: triggerEvent }),
});
setStep('done');
};
const label = score === null ? '' : score <= 6 ? 'Критик' : score <= 8 ? 'Нейтральный' : 'Промоутер';
return (
<div className="fixed bottom-6 right-6 w-80 rounded-xl bg-white shadow-xl p-5">
{step === 'score' && (
<>
<p className="font-semibold mb-3">Насколько вероятно, что вы порекомендуете нас?</p>
<div className="flex gap-1 justify-between mb-2">
{Array.from({ length: 11 }, (_, i) => (
<button key={i} onClick={() => { setScore(i); setStep('comment'); }}
className={`w-7 h-7 rounded text-sm ${i <= 6 ? 'bg-red-100' : i <= 8 ? 'bg-yellow-100' : 'bg-green-100'}`}>
{i}
</button>
))}
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>Маловероятно</span><span>Очень вероятно</span>
</div>
</>
)}
{step === 'comment' && (
<>
<p className="font-semibold mb-2">Оценка: {score} ({label}). Что повлияло на вашу оценку?</p>
<textarea value={comment} onChange={e => setComment(e.target.value)}
className="w-full border rounded p-2 text-sm h-20 resize-none" placeholder="Необязательно..." />
<button onClick={submit} className="mt-2 w-full bg-blue-600 text-white rounded py-1.5 text-sm">
Отправить
</button>
</>
)}
{step === 'done' && <p className="text-center text-green-600 font-medium">Спасибо за обратную связь!</p>}
</div>
);
}
Аналитика NPS
-- Расчёт NPS за период
SELECT
COUNT(*) FILTER (WHERE score >= 9)::float / COUNT(*) * 100 AS promoters_pct,
COUNT(*) FILTER (WHERE score <= 6)::float / COUNT(*) * 100 AS detractors_pct,
(COUNT(*) FILTER (WHERE score >= 9) - COUNT(*) FILTER (WHERE score <= 6))::float
/ COUNT(*) * 100 AS nps_score
FROM nps_responses
WHERE created_at >= now() - interval '90 days';
Сроки
Виджет NPS с условиями показа, API и базовой аналитикой: 3–4 рабочих дня.







