diff --git a/frontend/app/[locale]/login/page.tsx b/frontend/app/[locale]/login/page.tsx index ba3663f6..760bacb9 100644 --- a/frontend/app/[locale]/login/page.tsx +++ b/frontend/app/[locale]/login/page.tsx @@ -4,7 +4,7 @@ import { useLocale } from 'next-intl'; import { Link } from '@/i18n/routing'; import { useState } from "react"; import { useSearchParams } from "next/navigation"; -import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/guest-quiz"; +import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/quiz/guest-quiz"; import { Button } from "@/components/ui/button"; import { OAuthButtons } from '@/components/auth/OAuthButtons'; diff --git a/frontend/app/[locale]/quiz/[slug]/page.tsx b/frontend/app/[locale]/quiz/[slug]/page.tsx index e6a1ee75..87f32baf 100644 --- a/frontend/app/[locale]/quiz/[slug]/page.tsx +++ b/frontend/app/[locale]/quiz/[slug]/page.tsx @@ -1,3 +1,5 @@ +import { createEncryptedAnswersBlob } from '@/lib/quiz/quiz-crypto'; +import { stripCorrectAnswers } from '@/db/queries/quiz'; import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz'; import { notFound } from 'next/navigation'; import { getTranslations } from 'next-intl/server'; @@ -8,11 +10,13 @@ import { getCurrentUser } from '@/lib/auth'; interface QuizPageProps { params: Promise<{ locale: string; slug: string }>; + searchParams: Promise<{seed?: string}>; } -export default async function QuizPage({ params }: QuizPageProps) { +export default async function QuizPage({ params, searchParams }: QuizPageProps) { const { locale, slug } = await params; const t = await getTranslations({ locale, namespace: 'quiz.page' }); + const { seed: seedParam } = await searchParams; const user = await getCurrentUser(); @@ -22,9 +26,12 @@ export default async function QuizPage({ params }: QuizPageProps) { notFound(); } - const seed = Date.now(); + const seed = seedParam ? parseInt(seedParam, 10) : Date.now(); const questions = await getQuizQuestionsRandomized(quiz.id, locale, seed); + const encryptedAnswers = createEncryptedAnswersBlob(questions); + const clientQuestions = stripCorrectAnswers(questions); + if (!questions.length) { return (
@@ -56,9 +63,12 @@ export default async function QuizPage({ params }: QuizPageProps) { {user && }
diff --git a/frontend/app/[locale]/signup/page.tsx b/frontend/app/[locale]/signup/page.tsx index d11b2ac3..430e12bd 100644 --- a/frontend/app/[locale]/signup/page.tsx +++ b/frontend/app/[locale]/signup/page.tsx @@ -4,7 +4,7 @@ import { useLocale } from 'next-intl'; import { Link } from '@/i18n/routing'; import { useState } from "react"; import { useSearchParams } from "next/navigation"; -import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/guest-quiz"; +import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/quiz/guest-quiz"; import { Button } from "@/components/ui/button"; import { OAuthButtons } from '@/components/auth/OAuthButtons'; diff --git a/frontend/app/api/quiz/verify-answer/route.ts b/frontend/app/api/quiz/verify-answer/route.ts new file mode 100644 index 00000000..775a263e --- /dev/null +++ b/frontend/app/api/quiz/verify-answer/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { decryptAnswers } from '@/lib/quiz/quiz-crypto'; + +interface VerifyRequest { + questionId: string; + answerId: string; + encryptedAnswers: string; +} + +export async function POST(request: NextRequest) { + try { + const body: VerifyRequest = await request.json(); + const { questionId, answerId, encryptedAnswers } = body; + + if (!questionId || !answerId || !encryptedAnswers) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ); + } + + const correctAnswersMap = decryptAnswers(encryptedAnswers); + + if (!correctAnswersMap) { + return NextResponse.json( + { error: 'Invalid encrypted data' }, + { status: 400 } + ); + } + + const correctAnswerId = correctAnswersMap[questionId]; + + if (!correctAnswerId) { + return NextResponse.json( + { error: 'Question not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ + isCorrect: answerId === correctAnswerId, + }); + } catch { + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/frontend/components/dashboard/StatsCard.tsx b/frontend/components/dashboard/StatsCard.tsx index 5d297526..985c5c05 100644 --- a/frontend/components/dashboard/StatsCard.tsx +++ b/frontend/components/dashboard/StatsCard.tsx @@ -52,7 +52,7 @@ export function StatsCard({ stats }: StatsCardProps) {

Ready to level up? Challenge yourself with a new React quiz.

- + Start a Quiz
- + Continue Learning
diff --git a/frontend/components/quiz/PendingResultHandler.tsx b/frontend/components/quiz/PendingResultHandler.tsx index da5046c7..1bfe65a5 100644 --- a/frontend/components/quiz/PendingResultHandler.tsx +++ b/frontend/components/quiz/PendingResultHandler.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect } from 'react'; -import { getPendingQuizResult, clearPendingQuizResult } from '@/lib/guest-quiz'; +import { getPendingQuizResult, clearPendingQuizResult } from '@/lib/quiz/guest-quiz'; interface Props { userId: string; @@ -24,13 +24,13 @@ export function PendingResultHandler({ userId }: Props) { timeSpentSeconds: pending.timeSpentSeconds, }), }) - .then(async (res) => { - if (res.ok) { - clearPendingQuizResult(); - } else { - console.error("Guest-result API error:", res.status); - } - }) + .then(async (res) => { + if (res.ok) { + clearPendingQuizResult(); + } else { + console.error("Guest-result API error:", res.status); + } + }) .catch(err => { console.error("Guest-result fetch error:", err); }); diff --git a/frontend/components/quiz/QuizCard.tsx b/frontend/components/quiz/QuizCard.tsx index 678ba146..2bdd556f 100644 --- a/frontend/components/quiz/QuizCard.tsx +++ b/frontend/components/quiz/QuizCard.tsx @@ -28,7 +28,8 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) { : 0; return ( -
+
+
{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 (