22
33import { useLocale , useTranslations } from 'next-intl' ;
44import { useRouter , useSearchParams } from 'next/navigation' ;
5- import { useEffect } from 'react' ;
6- import { savePendingQuizResult } from '@/lib/quiz/guest-quiz' ;
7- import { useReducer , useTransition , useState } from 'react' ;
5+ import { useReducer , useTransition , useState , useEffect , useCallback } from 'react' ;
6+ import { toast } from 'sonner' ;
87import { useAntiCheat } from '@/hooks/useAntiCheat' ;
98import { useQuizSession } from '@/hooks/useQuizSession' ;
109import { useQuizGuards } from '@/hooks/useQuizGuards' ;
1110import { QuizProgress } from './QuizProgress' ;
1211import { QuizQuestion } from './QuizQuestion' ;
1312import { QuizResult } from './QuizResult' ;
1413import { CountdownTimer } from './CountdownTimer' ;
15- import { Button } from '@/components/ui/button' ;
1614import { submitQuizAttempt } from '@/actions/quiz' ;
1715import { clearQuizSession , type QuizSessionData } from '@/lib/quiz/quiz-session' ;
16+ import { savePendingQuizResult } from '@/lib/quiz/guest-quiz' ;
1817import type { QuizQuestionClient } from '@/db/queries/quiz' ;
1918import { ConfirmModal } from '@/components/ui/confirm-modal' ;
19+ import { Button } from '@/components/ui/button' ;
2020
2121interface Answer {
2222 questionId : string ;
@@ -58,12 +58,15 @@ function quizReducer(state: QuizState, action: QuizAction): QuizState {
5858 } ;
5959
6060 case 'ANSWER_SELECTED' :
61+ const answersWithoutThisQuestion = state . answers . filter (
62+ a => a . questionId !== action . payload . questionId
63+ ) ;
6164 return {
6265 ...state ,
6366 selectedAnswerId : action . payload . answerId ,
6467 questionStatus : 'revealed' ,
6568 answers : [
66- ...state . answers ,
69+ ...answersWithoutThisQuestion ,
6770 {
6871 questionId : action . payload . questionId ,
6972 selectedAnswerId : action . payload . answerId ,
@@ -144,6 +147,7 @@ export function QuizContainer({
144147} : QuizContainerProps ) {
145148 const tRules = useTranslations ( 'quiz.rules' ) ;
146149 const tExit = useTranslations ( 'quiz.exitModal' ) ;
150+ const tQuestion = useTranslations ( 'quiz.question' ) ;
147151 const [ isPending , startTransition ] = useTransition ( ) ;
148152 const [ state , dispatch ] = useReducer ( quizReducer , {
149153 status : 'rules' ,
@@ -156,7 +160,8 @@ export function QuizContainer({
156160 isIncomplete : false ,
157161 } ) ;
158162 const [ showExitModal , setShowExitModal ] = useState ( false ) ;
159-
163+ const [ isVerifyingAnswer , setIsVerifyingAnswer ] = useState ( false ) ;
164+
160165 const locale = useLocale ( ) ;
161166 const router = useRouter ( ) ;
162167 const searchParams = useSearchParams ( ) ;
@@ -169,10 +174,15 @@ export function QuizContainer({
169174 const currentQuestion = questions [ state . currentIndex ] ;
170175 const totalQuestions = questions . length ;
171176
172- useQuizSession ( {
177+ const handleRestoreSession = useCallback (
178+ ( data : QuizSessionData ) => dispatch ( { type : 'RESTORE_SESSION' , payload : data } ) ,
179+ [ ]
180+ ) ;
181+
182+ useQuizSession ( {
173183 quizId,
174184 state,
175- onRestore : data => dispatch ( { type : 'RESTORE_SESSION' , payload : data } ) ,
185+ onRestore : handleRestoreSession ,
176186} ) ;
177187
178188const { markQuitting } = useQuizGuards ( {
@@ -200,7 +210,7 @@ const { markQuitting } = useQuizGuards({
200210 dispatch ( { type : 'START_QUIZ' } ) ;
201211 } ;
202212
203- const handleAnswer = async ( answerId : string ) => {
213+ const verifyAnswer = async ( answerId : string ) => {
204214 const response = await fetch ( '/api/quiz/verify-answer' , {
205215 method : 'POST' ,
206216 headers : { 'Content-Type' : 'application/json' } ,
@@ -210,19 +220,60 @@ const { markQuitting } = useQuizGuards({
210220 encryptedAnswers,
211221 } ) ,
212222 } ) ;
213-
214- const { isCorrect } = await response . json ( ) ;
215223
216- dispatch ( {
217- type : 'ANSWER_SELECTED' ,
218- payload : {
219- answerId,
220- isCorrect,
221- questionId : currentQuestion . id ,
222- } ,
223- } ) ;
224+ if ( ! response . ok ) {
225+ throw new Error ( 'Verify answer failed' ) ;
226+ }
227+
228+ const data = await response . json ( ) ;
229+
230+ if ( typeof data . isCorrect !== 'boolean' ) {
231+ throw new Error ( 'Invalid verify response' ) ;
232+ }
233+
234+ return data . isCorrect ;
224235} ;
225236
237+ const sleep = ( ms : number ) => new Promise ( resolve => setTimeout ( resolve , ms ) ) ;
238+
239+
240+ const handleAnswer = async ( answerId : string ) => {
241+ if ( state . questionStatus !== 'answering' ) return ;
242+ if ( isVerifyingAnswer ) return ;
243+
244+ setIsVerifyingAnswer ( true ) ;
245+
246+ const maxRetries = 1 ;
247+ let attempt = 0 ;
248+
249+ try {
250+ while ( true ) {
251+ try {
252+ const isCorrect = await verifyAnswer ( answerId ) ;
253+
254+ dispatch ( {
255+ type : 'ANSWER_SELECTED' ,
256+ payload : {
257+ answerId,
258+ isCorrect,
259+ questionId : currentQuestion . id ,
260+ } ,
261+ } ) ;
262+ return ;
263+ } catch {
264+ if ( attempt >= maxRetries ) {
265+ toast . error ( tQuestion ( 'verifyFailed' ) ) ;
266+ return ;
267+ }
268+ attempt += 1 ;
269+ toast ( tQuestion ( 'verifyRetry' ) ) ;
270+ await sleep ( 600 ) ;
271+ }
272+ }
273+ } finally {
274+ setIsVerifyingAnswer ( false ) ;
275+ }
276+ } ;
226277
227278 const handleNext = ( ) => {
228279 if ( state . currentIndex + 1 >= totalQuestions ) {
@@ -373,7 +424,7 @@ const confirmQuit = () => {
373424 </ div >
374425 </ div >
375426
376- < Button onClick = { handleStart } className = "w-full" size = "lg " >
427+ < Button onClick = { handleStart } className = "w-full" size = "md " >
377428 { tRules ( 'startButton' ) }
378429 </ Button >
379430 </ div >
@@ -429,6 +480,7 @@ const confirmQuit = () => {
429480 timeLimitSeconds = { calculatedTime }
430481 onTimeUp = { handleTimeUp }
431482 isActive = { state . status === 'in_progress' }
483+ startedAt = { state . startedAt ! }
432484 />
433485 ) ;
434486 } ) ( ) }
0 commit comments