Реализация опросов и квизов
Квизы и опросы собирают обратную связь, проводят тестирования, помогают пользователям выбрать продукт. Ключевые требования: гибкая структура вопросов, поддержка разных типов ответов, отображение результатов.
Структура базы данных
CREATE TABLE quizzes (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
type VARCHAR(50) NOT NULL DEFAULT 'survey', -- survey|quiz|poll
settings JSONB NOT NULL DEFAULT '{}', -- show_results, randomize, time_limit
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE questions (
id SERIAL PRIMARY KEY,
quiz_id INTEGER REFERENCES quizzes(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL, -- single|multiple|text|scale|nps
text TEXT NOT NULL,
required BOOLEAN NOT NULL DEFAULT true,
sort_order INTEGER NOT NULL DEFAULT 0,
settings JSONB NOT NULL DEFAULT '{}'
);
CREATE TABLE options (
id SERIAL PRIMARY KEY,
question_id INTEGER REFERENCES questions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
score INTEGER NOT NULL DEFAULT 0, -- для квизов
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE responses (
id SERIAL PRIMARY KEY,
quiz_id INTEGER REFERENCES quizzes(id),
session_id VARCHAR(64), -- для анонимных
user_id INTEGER REFERENCES users(id),
answers JSONB NOT NULL, -- { question_id: answer_value }
score INTEGER,
completed BOOLEAN NOT NULL DEFAULT false,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ
);
Laravel API
class QuizController extends Controller
{
// Получить квиз с вопросами
public function show(Quiz $quiz): JsonResponse
{
$quiz->load(['questions' => fn($q) => $q->orderBy('sort_order')->with('options')]);
if ($quiz->settings['randomize'] ?? false) {
$quiz->questions = $quiz->questions->shuffle();
}
return response()->json(QuizResource::make($quiz));
}
// Сохранить ответы
public function submit(SubmitQuizRequest $request, Quiz $quiz): JsonResponse
{
$score = null;
if ($quiz->type === 'quiz') {
$score = $this->calculateScore($quiz, $request->answers);
}
$response = QuizResponse::create([
'quiz_id' => $quiz->id,
'user_id' => auth()->id(),
'session_id' => $request->session_id,
'answers' => $request->answers,
'score' => $score,
'completed' => true,
'finished_at' => now(),
]);
return response()->json([
'response_id' => $response->id,
'score' => $score,
'result' => $this->getResult($quiz, $score),
]);
}
private function calculateScore(Quiz $quiz, array $answers): int
{
$score = 0;
foreach ($quiz->questions as $question) {
$answer = $answers[$question->id] ?? null;
if ($answer === null) continue;
if ($question->type === 'single') {
$option = $question->options->find($answer);
$score += $option?->score ?? 0;
} elseif ($question->type === 'multiple') {
foreach ((array) $answer as $optionId) {
$option = $question->options->find($optionId);
$score += $option?->score ?? 0;
}
}
}
return $score;
}
}
React: компонент квиза
type QuestionType = 'single' | 'multiple' | 'text' | 'scale' | 'nps';
interface Question {
id: number;
type: QuestionType;
text: string;
options?: { id: number; text: string }[];
}
function QuizPlayer({ quiz }: { quiz: Quiz }) {
const [currentIdx, setCurrentIdx] = useState(0);
const [answers, setAnswers] = useState<Record<number, unknown>>({});
const question = quiz.questions[currentIdx];
const isLast = currentIdx === quiz.questions.length - 1;
const handleAnswer = (value: unknown) => {
setAnswers(prev => ({ ...prev, [question.id]: value }));
};
const handleNext = () => {
if (isLast) {
submitQuiz(answers);
} else {
setCurrentIdx(i => i + 1);
}
};
return (
<div className="quiz-player">
<div className="progress">
Вопрос {currentIdx + 1} из {quiz.questions.length}
</div>
<QuestionRenderer
question={question}
value={answers[question.id]}
onChange={handleAnswer}
/>
<button
onClick={handleNext}
disabled={question.required && answers[question.id] === undefined}
>
{isLast ? 'Завершить' : 'Далее'}
</button>
</div>
);
}
function QuestionRenderer({ question, value, onChange }: QuestionProps) {
switch (question.type) {
case 'single':
return (
<div className="options">
{question.options!.map(opt => (
<label key={opt.id} className="option">
<input
type="radio"
name={`q${question.id}`}
checked={value === opt.id}
onChange={() => onChange(opt.id)}
/>
{opt.text}
</label>
))}
</div>
);
case 'scale':
return (
<input
type="range" min={1} max={10}
value={value as number || 5}
onChange={e => onChange(Number(e.target.value))}
/>
);
case 'text':
return (
<textarea
value={value as string || ''}
onChange={e => onChange(e.target.value)}
rows={4}
/>
);
default:
return null;
}
}
Аналитика результатов
// Агрегация ответов для отчёта
public function analytics(Quiz $quiz): JsonResponse
{
$responses = QuizResponse::where('quiz_id', $quiz->id)
->where('completed', true)
->get();
$stats = $quiz->questions->map(function (Question $question) use ($responses) {
$answers = $responses->pluck("answers.{$question->id}")->filter();
return match ($question->type) {
'single', 'multiple' => [
'question_id' => $question->id,
'type' => $question->type,
'options' => $question->options->map(fn($opt) => [
'id' => $opt->id,
'text' => $opt->text,
'count' => $answers->filter(fn($a) => $a == $opt->id || in_array($opt->id, (array) $a))->count(),
'percent' => $answers->count() > 0 ? round($answers->filter(...)->count() / $answers->count() * 100) : 0,
]),
],
'scale', 'nps' => [
'question_id' => $question->id,
'avg' => round($answers->avg(), 1),
'distribution' => $answers->countBy()->toArray(),
],
default => ['question_id' => $question->id, 'answers' => $answers->take(50)],
};
});
return response()->json([
'total_responses' => $responses->count(),
'avg_score' => $responses->avg('score'),
'stats' => $stats,
]);
}
Срок реализации
Базовый квиз/опрос с типами single, multiple, text: 3–4 дня. С аналитикой, NPS, условными вопросами: 5–7 дней.







