+
{quiz.categoryName ?? t('uncategorized')}
{userProgress && (
@@ -39,7 +40,7 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
{quiz.title ?? quiz.slug}
{quiz.description && (
-
+
{quiz.description}
)}
@@ -49,27 +50,28 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) {
⏱️ {Math.floor((quiz.timeLimitSeconds ?? quiz.questionsCount * 30) / 60)} {t('min')}
+
{userProgress && (
-
-
-
- {t('best')} {userProgress.bestScore}/{userProgress.totalQuestions}
-
-
- {percentage}%
-
-
-
-
- {userProgress.attemptsCount} {userProgress.attemptsCount === 1 ? t('attempt') : t('attempts')}
-
-
- )}
+
+
+
+ {t('best')} {userProgress.bestScore}/{userProgress.totalQuestions}
+
+
+ {userProgress.attemptsCount} {userProgress.attemptsCount === 1 ? t('attempt') : t('attempts')}
+
+
+ {percentage}%
+
+
+
+
+ )}
({
+ ...a,
+ answeredAt: new Date(a.answeredAt),
+ })),
+ questionStatus: action.payload.questionStatus,
+ selectedAnswerId: action.payload.selectedAnswerId,
+ startedAt: action.payload.startedAt ? new Date(action.payload.startedAt) : null,
};
case 'RESTART':
@@ -90,6 +111,7 @@ function quizReducer(state: QuizState, action: QuizAction): QuizState {
selectedAnswerId: null,
startedAt: null,
pointsAwarded: null,
+ isIncomplete: false,
};
default:
@@ -99,10 +121,13 @@ function quizReducer(state: QuizState, action: QuizAction): QuizState {
interface QuizContainerProps {
quizId: string;
- questions: QuizQuestionWithAnswers[];
+ questions: QuizQuestionClient[];
+ encryptedAnswers: string;
userId: string | null;
quizSlug: string;
timeLimitSeconds: number | null;
+ seed: number;
+ categorySlug?: string | null;
onBackToTopics?: () => void;
}
@@ -110,11 +135,15 @@ export function QuizContainer({
quizSlug,
quizId,
questions,
+ encryptedAnswers,
userId,
timeLimitSeconds,
+ seed,
+ categorySlug,
onBackToTopics,
}: QuizContainerProps) {
const tRules = useTranslations('quiz.rules');
+ const tExit = useTranslations('quiz.exitModal');
const [isPending, startTransition] = useTransition();
const [state, dispatch] = useReducer(quizReducer, {
status: 'rules',
@@ -124,34 +153,76 @@ export function QuizContainer({
selectedAnswerId: null,
startedAt: null,
pointsAwarded: null,
+ isIncomplete: false,
});
-const locale = useLocale();
+ const [showExitModal, setShowExitModal] = useState(false);
+
+ const locale = useLocale();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
const isGuest = userId === null;
const { violations, violationsCount, resetViolations } = useAntiCheat(
state.status === 'in_progress'
);
-
const currentQuestion = questions[state.currentIndex];
const totalQuestions = questions.length;
+ useQuizSession({
+ quizId,
+ state,
+ onRestore: data => dispatch({ type: 'RESTORE_SESSION', payload: data }),
+});
+
+const { markQuitting } = useQuizGuards({
+ quizId,
+ status: state.status,
+ onExit: () => {
+ router.back();
+ },
+ resetViolations,
+});
+
+
+ // Sync seed to URL for language switch persistence
+ useEffect(() => {
+ if (!searchParams.has('seed')) {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set('seed', seed.toString());
+ router.replace(`?${params.toString()}`, { scroll: false });
+ }
+ }, [seed, searchParams, router]);
+
+
const handleStart = () => {
+ window.history.pushState({ quizGuard: true }, '');
dispatch({ type: 'START_QUIZ' });
};
- const handleAnswer = (answerId: string) => {
- const correctAnswer = currentQuestion.answers.find(a => a.isCorrect);
- const isCorrect = answerId === correctAnswer?.id;
-
- dispatch({
- type: 'ANSWER_SELECTED',
- payload: {
- answerId,
- isCorrect,
- questionId: currentQuestion.id,
- },
- });
- };
+ const handleAnswer = async (answerId: string) => {
+ const response = await fetch('/api/quiz/verify-answer', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ questionId: currentQuestion.id,
+ answerId,
+ encryptedAnswers,
+ }),
+ });
+
+ const { isCorrect } = await response.json();
+
+ dispatch({
+ type: 'ANSWER_SELECTED',
+ payload: {
+ answerId,
+ isCorrect,
+ questionId: currentQuestion.id,
+ },
+ });
+};
+
const handleNext = () => {
if (state.currentIndex + 1 >= totalQuestions) {
@@ -162,31 +233,40 @@ const locale = useLocale();
};
const handleSubmit = () => {
- const correctAnswers = state.answers.filter(a => a.isCorrect).length;
- const percentage = (correctAnswers / totalQuestions) * 100;
- const timeSpentSeconds = state.startedAt
- ? Math.floor((Date.now() - state.startedAt.getTime()) / 1000)
- : 0;
-
- if (isGuest) {
- savePendingQuizResult({
- quizId,
- quizSlug,
- answers: state.answers.map(a => ({
- questionId: a.questionId,
- selectedAnswerId: a.selectedAnswerId,
- isCorrect: a.isCorrect,
- })),
- score: correctAnswers,
- totalQuestions,
- percentage,
- violations: violations.map(v => ({ type: v.type, timestamp: v.timestamp.getTime() })),
- timeSpentSeconds,
- savedAt: Date.now(),
- });
- dispatch({ type: 'COMPLETE_QUIZ' });
- return;
- }
+ clearQuizSession(quizId);
+
+ const isIncomplete = state.answers.length < totalQuestions;
+
+ const correctAnswers = state.answers.filter(a => a.isCorrect).length;
+ const percentage = (correctAnswers / totalQuestions) * 100;
+ const timeSpentSeconds = state.startedAt
+ ? Math.floor((Date.now() - state.startedAt.getTime()) / 1000)
+ : 0;
+
+ if (isIncomplete) {
+ dispatch({ type: 'COMPLETE_QUIZ', payload: { isIncomplete: true } });
+ return;
+ }
+
+ if (isGuest) {
+ savePendingQuizResult({
+ quizId,
+ quizSlug,
+ answers: state.answers.map(a => ({
+ questionId: a.questionId,
+ selectedAnswerId: a.selectedAnswerId,
+ isCorrect: a.isCorrect,
+ })),
+ score: correctAnswers,
+ totalQuestions,
+ percentage,
+ violations: violations.map(v => ({ type: v.type, timestamp: v.timestamp.getTime() })),
+ timeSpentSeconds,
+ savedAt: Date.now(),
+ });
+ dispatch({ type: 'COMPLETE_QUIZ' });
+ return;
+ }
startTransition(async () => {
const result = await submitQuizAttempt({
userId,
@@ -199,9 +279,9 @@ const locale = useLocale();
});
if (result.success) {
- dispatch({
- type: 'COMPLETE_QUIZ',
- payload: { pointsAwarded: result.pointsAwarded ?? 0 }
+ dispatch({
+ type: 'COMPLETE_QUIZ',
+ payload: { pointsAwarded: result.pointsAwarded ?? 0 }
});
} else {
console.error('Failed to submit quiz:', result.error);
@@ -211,10 +291,24 @@ const locale = useLocale();
};
const handleRestart = () => {
+ clearQuizSession(quizId);
resetViolations();
dispatch({ type: 'RESTART' });
};
+const handleQuit = () => {
+ setShowExitModal(true);
+};
+
+const confirmQuit = () => {
+ markQuitting();
+ clearQuizSession(quizId);
+ resetViolations();
+ const categoryParam = categorySlug ? `?category=${categorySlug}` : '';
+ window.location.href = `/${locale}/quizzes${categoryParam}`;
+};
+
+
const handleTimeUp = () => {
handleSubmit();
};
@@ -223,7 +317,7 @@ const locale = useLocale();
if (onBackToTopics) {
onBackToTopics();
} else {
- window.location.href = `/${locale}/`;
+ window.location.href = `/${locale}/`;
}
};
@@ -298,6 +392,7 @@ const locale = useLocale();
answeredCount={state.answers.length}
violationsCount={violationsCount}
pointsAwarded={state.pointsAwarded}
+ isIncomplete={state.isIncomplete}
onRestart={handleRestart}
onBackToTopics={handleBackToTopicsClick}
isGuest={isGuest}
@@ -308,6 +403,19 @@ const locale = useLocale();
return (
+
a.questionId === currentQuestion.id)?.isCorrect ?? false}
onAnswer={handleAnswer}
onNext={handleNext}
isLoading={isPending}
/>
+ setShowExitModal(false)}
+ />
);
}
diff --git a/frontend/components/quiz/QuizQuestion.tsx b/frontend/components/quiz/QuizQuestion.tsx
index c6ec2f56..0ba41e6d 100644
--- a/frontend/components/quiz/QuizQuestion.tsx
+++ b/frontend/components/quiz/QuizQuestion.tsx
@@ -1,16 +1,17 @@
'use client';
import { useTranslations } from 'next-intl';
-import { QuizQuestionWithAnswers } from '@/db/queries/quiz';
+import { QuizQuestionClient } from '@/db/queries/quiz';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import ExplanationRenderer from './ExplanationRenderer';
import { cn } from '@/lib/utils';
interface QuizQuestionProps {
- question: QuizQuestionWithAnswers;
+ question: QuizQuestionClient;
status: 'answering' | 'revealed';
selectedAnswerId: string | null;
+ isCorrect: boolean;
onAnswer: (answerId: string) => void;
onNext: () => void;
isLoading?: boolean;
@@ -20,6 +21,7 @@ export function QuizQuestion({
question,
status,
selectedAnswerId,
+ isCorrect,
onAnswer,
onNext,
isLoading = false,
@@ -28,8 +30,7 @@ export function QuizQuestion({
const isAnswering = status === 'answering';
const isRevealed = status === 'revealed';
- const correctAnswer = question.answers.find(a => a.isCorrect);
- const isCorrectAnswer = isRevealed && selectedAnswerId === correctAnswer?.id;
+ const isCorrectAnswer = isRevealed && isCorrect;
return (
@@ -44,8 +45,8 @@ export function QuizQuestion({
>
{question.answers.map(answer => {
const isSelected = selectedAnswerId === answer.id;
- const showCorrect = isRevealed && isCorrectAnswer && isSelected;
- const showIncorrect = isRevealed && !isCorrectAnswer && isSelected;
+ const showCorrect = isRevealed && isSelected && isCorrect;
+ const showIncorrect = isRevealed && isSelected && !isCorrect;
return (