From 8585ccb8549bc83eb08f34e80c7b42b47e712ab6 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova Date: Tue, 13 Jan 2026 19:56:04 +0200 Subject: [PATCH] feat(quiz): timer fix, answer verification retry, new quiz seeds - Fix timer restart on language switch (use startedAt prop) - Fix progress bar initial state calculation - Add answer verification retry with toast notifications - Hide score display for incomplete quizzes - Fix guest message condition for incomplete results - Add Button accent variant (prepared for future use) - Reorder categories: Next.js before Angular - Add seed quizzes: Angular, Vue, Node.js (40 questions each) --- .gitignore | 1 + frontend/components/quiz/CountdownTimer.tsx | 10 +- frontend/components/quiz/QuizContainer.tsx | 92 +++- frontend/components/quiz/QuizResult.tsx | 50 +- frontend/components/ui/button.tsx | 4 +- frontend/data/category.ts | 4 +- frontend/db/seed-quiz-angular-advanced.ts | 244 ++++++++++ frontend/db/seed-quiz-angular.ts | 244 ++++++++++ frontend/db/seed-quiz-nodejs-advanced.ts | 244 ++++++++++ frontend/db/seed-quiz-nodejs.ts | 244 ++++++++++ frontend/db/seed-quiz-vue.ts | 243 ++++++++++ frontend/messages/en.json | 4 +- frontend/messages/pl.json | 6 +- frontend/messages/uk.json | 4 +- .../advanced/angular-advanced-quiz-part1.json | 434 ++++++++++++++++++ .../advanced/angular-advanced-quiz-part2.json | 434 ++++++++++++++++++ .../advanced/angular-advanced-quiz-part3.json | 434 ++++++++++++++++++ .../advanced/angular-advanced-quiz-part4.json | 434 ++++++++++++++++++ .../advanced/seed-quiz-angular-advanced.ts | 240 ++++++++++ .../beginner_medium/angular-quiz-part1.json | 434 ++++++++++++++++++ .../beginner_medium/angular-quiz-part2.json | 434 ++++++++++++++++++ .../beginner_medium/angular-quiz-part3.json | 434 ++++++++++++++++++ .../beginner_medium/angular-quiz-part4.json | 434 ++++++++++++++++++ .../beginner_medium/seed-quiz-angular.ts | 240 ++++++++++ .../advanced/nodejs-advanced-quiz-part1.json | 434 ++++++++++++++++++ .../advanced/nodejs-advanced-quiz-part2.json | 434 ++++++++++++++++++ .../advanced/nodejs-advanced-quiz-part3.json | 434 ++++++++++++++++++ .../advanced/nodejs-advanced-quiz-part4.json | 434 ++++++++++++++++++ .../advanced/seed-quiz-nodejs-advanced.ts | 240 ++++++++++ .../beginner_medium/nodejs-quiz-part1.json | 434 ++++++++++++++++++ .../beginner_medium/nodejs-quiz-part2.json | 434 ++++++++++++++++++ .../beginner_medium/nodejs-quiz-part3.json | 434 ++++++++++++++++++ .../beginner_medium/nodejs-quiz-part4.json | 434 ++++++++++++++++++ .../seed-quiz-nodejs-fundamentals.ts | 240 ++++++++++ .../vue/beginner_medium/seed-quiz-vue.ts | 239 ++++++++++ .../vue/beginner_medium/vue-quiz-part1.json | 434 ++++++++++++++++++ .../vue/beginner_medium/vue-quiz-part2.json | 434 ++++++++++++++++++ .../vue/beginner_medium/vue-quiz-part3.json | 434 ++++++++++++++++++ .../vue/beginner_medium/vue-quiz-part4.json | 434 ++++++++++++++++++ 39 files changed, 11220 insertions(+), 53 deletions(-) create mode 100644 frontend/db/seed-quiz-angular-advanced.ts create mode 100644 frontend/db/seed-quiz-angular.ts create mode 100644 frontend/db/seed-quiz-nodejs-advanced.ts create mode 100644 frontend/db/seed-quiz-nodejs.ts create mode 100644 frontend/db/seed-quiz-vue.ts create mode 100644 json/quizzes/angular/advanced/angular-advanced-quiz-part1.json create mode 100644 json/quizzes/angular/advanced/angular-advanced-quiz-part2.json create mode 100644 json/quizzes/angular/advanced/angular-advanced-quiz-part3.json create mode 100644 json/quizzes/angular/advanced/angular-advanced-quiz-part4.json create mode 100644 json/quizzes/angular/advanced/seed-quiz-angular-advanced.ts create mode 100644 json/quizzes/angular/beginner_medium/angular-quiz-part1.json create mode 100644 json/quizzes/angular/beginner_medium/angular-quiz-part2.json create mode 100644 json/quizzes/angular/beginner_medium/angular-quiz-part3.json create mode 100644 json/quizzes/angular/beginner_medium/angular-quiz-part4.json create mode 100644 json/quizzes/angular/beginner_medium/seed-quiz-angular.ts create mode 100644 json/quizzes/node/advanced/nodejs-advanced-quiz-part1.json create mode 100644 json/quizzes/node/advanced/nodejs-advanced-quiz-part2.json create mode 100644 json/quizzes/node/advanced/nodejs-advanced-quiz-part3.json create mode 100644 json/quizzes/node/advanced/nodejs-advanced-quiz-part4.json create mode 100644 json/quizzes/node/advanced/seed-quiz-nodejs-advanced.ts create mode 100644 json/quizzes/node/beginner_medium/nodejs-quiz-part1.json create mode 100644 json/quizzes/node/beginner_medium/nodejs-quiz-part2.json create mode 100644 json/quizzes/node/beginner_medium/nodejs-quiz-part3.json create mode 100644 json/quizzes/node/beginner_medium/nodejs-quiz-part4.json create mode 100644 json/quizzes/node/beginner_medium/seed-quiz-nodejs-fundamentals.ts create mode 100644 json/quizzes/vue/beginner_medium/seed-quiz-vue.ts create mode 100644 json/quizzes/vue/beginner_medium/vue-quiz-part1.json create mode 100644 json/quizzes/vue/beginner_medium/vue-quiz-part2.json create mode 100644 json/quizzes/vue/beginner_medium/vue-quiz-part3.json create mode 100644 json/quizzes/vue/beginner_medium/vue-quiz-part4.json diff --git a/.gitignore b/.gitignore index e7d1e0e3..3f78fe62 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ frontend/.env.bak frontend/.env*.bak frontend/.env.bak +tmpclaude-* diff --git a/frontend/components/quiz/CountdownTimer.tsx b/frontend/components/quiz/CountdownTimer.tsx index 55dcead5..01bb05c2 100644 --- a/frontend/components/quiz/CountdownTimer.tsx +++ b/frontend/components/quiz/CountdownTimer.tsx @@ -8,17 +8,21 @@ interface CountdownTimerProps { timeLimitSeconds: number; onTimeUp: () => void; isActive: boolean; + startedAt: Date; } export function CountdownTimer({ timeLimitSeconds, onTimeUp, isActive, + startedAt, }: CountdownTimerProps) { const t = useTranslations('quiz.timer'); - const [endTime] = useState(() => Date.now() + timeLimitSeconds * 1000); - const [remainingSeconds, setRemainingSeconds] = useState(timeLimitSeconds); - + const endTime = startedAt.getTime() + timeLimitSeconds * 1000; + const [remainingSeconds, setRemainingSeconds] = useState(() => + Math.max(0, Math.floor((endTime - Date.now()) / 1000)) + ); + useEffect(() => { if (!isActive) return; diff --git a/frontend/components/quiz/QuizContainer.tsx b/frontend/components/quiz/QuizContainer.tsx index a17c9339..e00896a3 100644 --- a/frontend/components/quiz/QuizContainer.tsx +++ b/frontend/components/quiz/QuizContainer.tsx @@ -2,9 +2,8 @@ import { useLocale, useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useEffect } from 'react'; -import { savePendingQuizResult } from '@/lib/quiz/guest-quiz'; -import { useReducer, useTransition, useState } from 'react'; +import { useReducer, useTransition, useState, useEffect, useCallback } from 'react'; +import { toast } from 'sonner'; import { useAntiCheat } from '@/hooks/useAntiCheat'; import { useQuizSession } from '@/hooks/useQuizSession'; import { useQuizGuards } from '@/hooks/useQuizGuards'; @@ -12,11 +11,12 @@ import { QuizProgress } from './QuizProgress'; import { QuizQuestion } from './QuizQuestion'; import { QuizResult } from './QuizResult'; import { CountdownTimer } from './CountdownTimer'; -import { Button } from '@/components/ui/button'; import { submitQuizAttempt } from '@/actions/quiz'; import { clearQuizSession, type QuizSessionData } from '@/lib/quiz/quiz-session'; +import { savePendingQuizResult } from '@/lib/quiz/guest-quiz'; import type { QuizQuestionClient } from '@/db/queries/quiz'; import { ConfirmModal } from '@/components/ui/confirm-modal'; +import { Button } from '@/components/ui/button'; interface Answer { questionId: string; @@ -58,12 +58,15 @@ function quizReducer(state: QuizState, action: QuizAction): QuizState { }; case 'ANSWER_SELECTED': + const answersWithoutThisQuestion = state.answers.filter( + a => a.questionId !== action.payload.questionId + ); return { ...state, selectedAnswerId: action.payload.answerId, questionStatus: 'revealed', answers: [ - ...state.answers, + ...answersWithoutThisQuestion, { questionId: action.payload.questionId, selectedAnswerId: action.payload.answerId, @@ -144,6 +147,7 @@ export function QuizContainer({ }: QuizContainerProps) { const tRules = useTranslations('quiz.rules'); const tExit = useTranslations('quiz.exitModal'); + const tQuestion = useTranslations('quiz.question'); const [isPending, startTransition] = useTransition(); const [state, dispatch] = useReducer(quizReducer, { status: 'rules', @@ -156,7 +160,8 @@ export function QuizContainer({ isIncomplete: false, }); const [showExitModal, setShowExitModal] = useState(false); - + const [isVerifyingAnswer, setIsVerifyingAnswer] = useState(false); + const locale = useLocale(); const router = useRouter(); const searchParams = useSearchParams(); @@ -169,10 +174,15 @@ export function QuizContainer({ const currentQuestion = questions[state.currentIndex]; const totalQuestions = questions.length; - useQuizSession({ + const handleRestoreSession = useCallback( + (data: QuizSessionData) => dispatch({ type: 'RESTORE_SESSION', payload: data }), + [] +); + +useQuizSession({ quizId, state, - onRestore: data => dispatch({ type: 'RESTORE_SESSION', payload: data }), + onRestore: handleRestoreSession, }); const { markQuitting } = useQuizGuards({ @@ -200,7 +210,7 @@ const { markQuitting } = useQuizGuards({ dispatch({ type: 'START_QUIZ' }); }; - const handleAnswer = async (answerId: string) => { + const verifyAnswer = async (answerId: string) => { const response = await fetch('/api/quiz/verify-answer', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -210,19 +220,60 @@ const { markQuitting } = useQuizGuards({ encryptedAnswers, }), }); - - const { isCorrect } = await response.json(); - dispatch({ - type: 'ANSWER_SELECTED', - payload: { - answerId, - isCorrect, - questionId: currentQuestion.id, - }, - }); + if (!response.ok) { + throw new Error('Verify answer failed'); + } + + const data = await response.json(); + + if (typeof data.isCorrect !== 'boolean') { + throw new Error('Invalid verify response'); + } + + return data.isCorrect; }; +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + +const handleAnswer = async (answerId: string) => { + if (state.questionStatus !== 'answering') return; + if (isVerifyingAnswer) return; + + setIsVerifyingAnswer(true); + + const maxRetries = 1; + let attempt = 0; + + try { + while (true) { + try { + const isCorrect = await verifyAnswer(answerId); + + dispatch({ + type: 'ANSWER_SELECTED', + payload: { + answerId, + isCorrect, + questionId: currentQuestion.id, + }, + }); + return; + } catch { + if (attempt >= maxRetries) { + toast.error(tQuestion('verifyFailed')); + return; + } + attempt += 1; + toast(tQuestion('verifyRetry')); + await sleep(600); + } + } + } finally { + setIsVerifyingAnswer(false); + } +}; const handleNext = () => { if (state.currentIndex + 1 >= totalQuestions) { @@ -373,7 +424,7 @@ const confirmQuit = () => { - @@ -429,6 +480,7 @@ const confirmQuit = () => { timeLimitSeconds={calculatedTime} onTimeUp={handleTimeUp} isActive={state.status === 'in_progress'} + startedAt={state.startedAt!} /> ); })()} diff --git a/frontend/components/quiz/QuizResult.tsx b/frontend/components/quiz/QuizResult.tsx index 41acf898..bc3ae888 100644 --- a/frontend/components/quiz/QuizResult.tsx +++ b/frontend/components/quiz/QuizResult.tsx @@ -90,27 +90,31 @@ export function QuizResult({ return (
{motivation.emoji}
-
-

- {score} / {total} -

-

- {percentage.toFixed(0)}% {t('correctAnswers')} -

-
-
-
-
= 50 && percentage < 80 && 'bg-orange-500', - percentage >= 80 && 'bg-green-500' - )} - style={{ width: `${percentage}%` }} - /> -
-
+ {!isIncomplete && ( + <> +
+

+ {score} / {total} +

+

+ {percentage.toFixed(0)}% {t('correctAnswers')} +

+
+
+
+
= 50 && percentage < 80 && 'bg-orange-500', + percentage >= 80 && 'bg-green-500' + )} + style={{ width: `${percentage}%` }} + /> +
+
+ + )}

{motivation.title} @@ -141,7 +145,7 @@ export function QuizResult({

)} - {isGuest ? ( + {isGuest && !isIncomplete ? (

@@ -154,7 +158,7 @@ export function QuizResult({ const url = `/${locale}/login?returnTo=/quiz/${quizSlug}`; window.location.href = url; }} - variant="primary"> + > {t('loginButton')}