diff --git a/backend/http/endpoint.http b/backend/http/endpoint.http index 0b124a0..221bf63 100644 --- a/backend/http/endpoint.http +++ b/backend/http/endpoint.http @@ -9,7 +9,6 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImVtYWlsI "password": "FatimaAminu@123" } - POST http://localhost:3000/auth/signIn Content-Type: application/json @@ -27,8 +26,25 @@ Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImVtYWlsI } -GET http://localhost:3000/daily-quest -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImVtYWlsIjoiYW1pbnVmYXRpbWFAZ21haWwuY29tIiwiaWF0IjoxNzY5MzI2NTA3LCJleHAiOjE3NjkzMzAxMDcsImF1ZCI6ImxvY2FsaG9zdDozMDAwIiwiaXNzIjoibG9jYWxob3N0OjMwMDAifQ.YnrXEo1yns77DQgzHR0XO8m5MfTxT_ic9U_2je9nB6M +GET http://localhost:3000/puzzles/daily-quest + +### Get all puzzles +GET http://localhost:3000/puzzles + +### Get puzzles by category +GET http://localhost:3000/puzzles?categoryId=397b1a88-3a41-4c14-afee-20f57554368b + +### Get puzzles by difficulty +GET http://localhost:3000/puzzles?difficulty=INTERMEDIATE + +### Get puzzles by category AND difficulty +GET http://localhost:3000/puzzles?categoryId=397b1a88-3a41-4c14-afee-20f57554368b&difficulty=INTERMEDIATE + +### Get puzzles with pagination +GET http://localhost:3000/puzzles?page=1&limit=10 + +### Get puzzles with all filters +GET http://localhost:3000/puzzles?categoryId=397b1a88-3a41-4c14-afee-20f57554368b&difficulty=INTERMEDIATE&page=1&limit=10 diff --git a/backend/src/puzzles/controllers/puzzles.controller.ts b/backend/src/puzzles/controllers/puzzles.controller.ts index 8aee050..9d7461f 100644 --- a/backend/src/puzzles/controllers/puzzles.controller.ts +++ b/backend/src/puzzles/controllers/puzzles.controller.ts @@ -29,17 +29,6 @@ export class PuzzlesController { return this.puzzlesService.create(createPuzzleDto); } - @ApiOperation({ summary: 'Get a puzzle by ID' }) - @ApiResponse({ - status: 200, - description: 'Puzzle retrieved successfully', - type: Puzzle, - }) - @Get(':id') - getById(@Param('id') id: string) { - return this.puzzlesService.getPuzzleById(id); - } - @ApiOperation({ summary: 'Get daily quest puzzles' }) @ApiResponse({ status: 200, @@ -60,4 +49,15 @@ export class PuzzlesController { findAll(@Query() query: PuzzleQueryDto) { return this.puzzlesService.findAll(query); } + + @ApiOperation({ summary: 'Get a puzzle by ID' }) + @ApiResponse({ + status: 200, + description: 'Puzzle retrieved successfully', + type: Puzzle, + }) + @Get(':id') + getById(@Param('id') id: string) { + return this.puzzlesService.getPuzzleById(id); + } } \ No newline at end of file diff --git a/frontend/app/auth/check-email/page.tsx b/frontend/app/auth/check-email/page.tsx index 159aa72..bceef9b 100644 --- a/frontend/app/auth/check-email/page.tsx +++ b/frontend/app/auth/check-email/page.tsx @@ -1,12 +1,10 @@ "use client"; import ErrorBoundary from "@/components/ErrorBoundary"; -import Link from "next/link"; -import Image from 'next/image'; import { useToast } from "@/components/ui/ToastProvider"; import { useState } from "react"; import Button from "@/components/ui/Button"; -import { Mail } from "lucide-react"; + const CheckEmail = () => { const { showSuccess, showError } = useToast(); @@ -40,7 +38,8 @@ const CheckEmail = () => { } else { showError('Error', data.message || 'Failed to resend password reset link.'); } - } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { showError('Error', 'An unexpected error occurred. Please try again later.'); } finally { setIsLoading(false); diff --git a/frontend/app/auth/forgot-password/page.tsx b/frontend/app/auth/forgot-password/page.tsx index c6e02c2..55c32e3 100644 --- a/frontend/app/auth/forgot-password/page.tsx +++ b/frontend/app/auth/forgot-password/page.tsx @@ -12,7 +12,7 @@ import Button from "@/components/ui/Button"; const ForgotPassword = () => { const router = useRouter(); - const { showSuccess, showError, showWarning, showInfo } = useToast(); + const { showSuccess, showError } = useToast(); const [formData, setFormData] = useState({ email: '', }); @@ -52,25 +52,35 @@ const ForgotPassword = () => { } try { - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/forgot-password`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/auth/forgot-password`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: formData.email }), }, - body: JSON.stringify({ email: formData.email }), - }); - + ); + const data = await response.json(); - + if (response.ok) { // Store email for resend functionality - sessionStorage.setItem('resetEmail', formData.email); - showSuccess('Success', data.message || 'Password reset link sent to your email.'); - router.push('/auth/check-email'); + sessionStorage.setItem("resetEmail", formData.email); + showSuccess( + "Success", + data.message || "Password reset link sent to your email.", + ); + router.push("/auth/check-email"); } else { - showError('Error', data.message || 'Failed to send password reset link.'); + showError( + "Error", + data.message || "Failed to send password reset link.", + ); } - } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { showError('Error', 'An unexpected error occurred. Please try again later.'); } finally { setIsLoading(false); diff --git a/frontend/app/auth/reset-password/[token]/page.tsx b/frontend/app/auth/reset-password/[token]/page.tsx index 583b756..4d94930 100644 --- a/frontend/app/auth/reset-password/[token]/page.tsx +++ b/frontend/app/auth/reset-password/[token]/page.tsx @@ -1,8 +1,7 @@ "use client"; import ErrorBoundary from "@/components/ErrorBoundary"; -import Link from "next/link"; -import Image from 'next/image'; + import { useRouter, useParams } from "next/navigation"; import Input from "@/components/ui/Input"; import { useToast } from "@/components/ui/ToastProvider"; @@ -76,31 +75,39 @@ const ResetPassword = () => { } try { - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/reset-password/${token}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/auth/reset-password/${token}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + password: formData.password, + }), }, - body: JSON.stringify({ - password: formData.password, - }), - }); + ); const data = await response.json(); if (response.ok) { - showSuccess('Success', data.message || 'Password reset successfully.'); + showSuccess("Success", data.message || "Password reset successfully."); // Clear the stored email - sessionStorage.removeItem('resetEmail'); - localStorage.removeItem('resetEmail'); + sessionStorage.removeItem("resetEmail"); + localStorage.removeItem("resetEmail"); // Redirect to sign in after 1.5 seconds setTimeout(() => { - router.push('/auth/signin'); + router.push("/auth/signin"); }, 1500); } else { - showError('Error', data.message || 'Failed to reset password. The link may have expired.'); + showError( + "Error", + data.message || + "Failed to reset password. The link may have expired.", + ); } - } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { showError('Error', 'An unexpected error occurred. Please try again later.'); } finally { setIsLoading(false); diff --git a/frontend/app/quiz/page.tsx b/frontend/app/quiz/page.tsx index 20d6e78..b1184d6 100644 --- a/frontend/app/quiz/page.tsx +++ b/frontend/app/quiz/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useRef, useEffect } from "react"; import { Nunito } from "next/font/google"; -import { MOCK_QUIZ } from "@/lib/Quiz_data"; -import { QuizHeader } from "@/components/quiz/QuizHeader"; -import { AnswerOption } from "@/components/quiz/AnswerOption"; -import { LevelComplete } from "@/components/quiz/LevelComplete"; +import { useQuiz } from "../../hooks/useQuiz"; +import { useAppSelector } from "../../lib/reduxHooks"; +import { QuizHeader } from "../../components/quiz/QuizHeader"; +import { AnswerOption } from "../../components/quiz/AnswerOption"; +import { LevelComplete } from "../../components/quiz/LevelComplete"; const nunito = Nunito({ subsets: ["latin"], @@ -13,83 +14,136 @@ const nunito = Nunito({ }); export default function QuizPage() { - const [step, setStep] = useState(0); - const [selectedId, setSelectedId] = useState(null); - const [isSubmitted, setIsSubmitted] = useState(false); - const [score, setScore] = useState(0); - const [isFinished, setIsFinished] = useState(false); - const actionBtnRef = useRef(null); - const QUIZ = MOCK_QUIZ.slice(0, 2); - const question = QUIZ[step]; + const { + questions, + currentQuestion, + currentQuestionIndex, + selectedAnswerId, + isLoading, + isSubmitting, + isFinished, + submissionResult, + error, + score, + correctAnswersCount, + selectAnswer, + submitAnswer, + goToNextQuestion, + } = useQuiz({ + autoFetch: true, + fetchParams: { type: "daily-quest" }, + }); - const handleSelectOption = (optionId: string) => { - if (isSubmitted) return; - setSelectedId(optionId); - }; + const quizState = useAppSelector((state) => state.quiz); + + const isSubmitted = submissionResult !== null; useEffect(() => { - if (selectedId && actionBtnRef.current) { + if (selectedAnswerId && actionBtnRef.current) { actionBtnRef.current.focus(); } - }, [selectedId]); + }, [selectedAnswerId]); - const handleAction = () => { + const handleAction = async () => { if (!isSubmitted) { - setTimeout(() => { - setIsSubmitted(true); - const selectedOption = question.options.find( - (opt) => opt.id === selectedId, - ); - if (selectedOption?.isCorrect) { - setScore((prev) => prev + 1); - } - }, 150); - } else { - if (step < QUIZ.length - 1) { - setStep(step + 1); - setSelectedId(null); - setIsSubmitted(false); - } else { - setIsFinished(true); + try { + await submitAnswer(); + } catch (err) { + console.error("Failed to submit answer:", err); } + } else { + goToNextQuestion(); } }; + // Format time taken from total session time + const formatTimeTaken = () => { + const totalSeconds = Math.floor(quizState.totalSessionTime / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; + }; + + // Loading state + if (isLoading) { + return ( +
+
Loading questions...
+
+ ); + } + + // Error state + if (error && questions.length === 0) { + return ( +
+
Error: {error}
+
+ ); + } + + // No questions state + if (!currentQuestion && !isLoading) { + return ( +
+
No questions available
+
+ ); + } + + if (!currentQuestion) { + return null; + } + return (
- {!isFinished && } + {!isFinished && ( + + )}
{isFinished ? ( alert("Points Claimed!")} /> ) : (

- {question.question} + {currentQuestion.text}

Points: {score * 10}

- {question.options.map((opt) => { - const isSelected = selectedId === opt.id; + {currentQuestion.options.map((optionText, index) => { + const isSelected = selectedAnswerId === optionText; let state: "default" | "red" | "green" | "teal" = "default"; - if (isSubmitted) { + if (isSubmitted && submissionResult) { if (isSelected) { - state = opt.isCorrect ? "teal" : "red"; - } else if (opt.isCorrect) { - state = "teal"; + state = submissionResult.isCorrect ? "green" : "red"; + } else if (submissionResult.isCorrect) { + // Show correct answer in green even if not selected + // Note: We don't know which option is correct from backend + // This would need backend to return correctAnswer in response + state = "default"; } } else if (isSelected) { state = "teal"; @@ -97,55 +151,48 @@ export default function QuizPage() { return ( handleSelectOption(opt.id)} + disabled={isSubmitted || isSubmitting} + onSelect={() => selectAnswer(optionText)} /> ); })}
- {isSubmitted && ( -
- {(() => { - const selectedOption = question.options.find( - (o) => o.id === selectedId, - ); - const correctOption = question.options.find( - (o) => o.isCorrect, - ); - const wasCorrect = !!selectedOption?.isCorrect; - return ( - <> - {!wasCorrect && correctOption && ( -
- Correct answer: {correctOption.text} -
- )} - {question.explanation && ( -

- {question.explanation} -

- )} - - ); - })()} + {isSubmitted && submissionResult && ( +
+
+ {submissionResult.isCorrect + ? `Correct! +${submissionResult.pointsEarned} pts` + : "Incorrect"} +
+

+ Note: backend does not return the correct option, so we only highlight your selected answer. +

)}
)} diff --git a/frontend/hooks/useQuiz.ts b/frontend/hooks/useQuiz.ts new file mode 100644 index 0000000..9bb21fe --- /dev/null +++ b/frontend/hooks/useQuiz.ts @@ -0,0 +1,158 @@ +"use client"; + +import { useEffect, useCallback } from "react"; +import { useAppDispatch, useAppSelector } from "../lib/reduxHooks"; +import { + fetchQuestions, + submitAnswerThunk, + selectAnswer, + nextQuestion, + startQuiz, + type FetchQuestionsParams, +} from "../lib/features/quiz/quizSlice"; +import type { Question } from "../lib/features/quiz/quizSlice"; + +function getUserIdFromToken(): string | null { + if (typeof window === "undefined") { + return null; + } + + const token = localStorage.getItem("accessToken"); + if (!token) { + return null; + } + + try { + // Decode JWT token (base64url decode the payload) + const parts = token.split("."); + if (parts.length !== 3) { + return null; + } + + const payload = parts[1]; + const decoded = JSON.parse( + atob(payload.replace(/-/g, "+").replace(/_/g, "/")), + ); + return decoded.sub || null; + } catch (error) { + console.error("Failed to decode token:", error); + return null; + } +} + +export interface UseQuizOptions { + autoFetch?: boolean; + fetchParams?: FetchQuestionsParams; +} + +export function useQuiz(options: UseQuizOptions = {}) { + const { autoFetch = false, fetchParams = { type: "daily-quest" } } = + options; + + const dispatch = useAppDispatch(); + const quizState = useAppSelector((state) => state.quiz); + + const { + questions, + currentIndex, + selectedAnswerId, + isSubmitting, + submissionResult, + status, + error, + questionStartTime, + score, + correctAnswersCount, + } = quizState; + + const currentQuestion: Question | null = + questions.length > 0 && currentIndex < questions.length + ? questions[currentIndex] + : null; + + const isLoading = status === "loading"; + const isFinished = status === "completed"; + + // Auto-fetch questions on mount if requested + useEffect(() => { + if (autoFetch && questions.length === 0 && status === "idle") { + dispatch(fetchQuestions(fetchParams)); + } + }, [autoFetch, questions.length, status, fetchParams, dispatch]); + + // Start quiz when questions are loaded + useEffect(() => { + if ( + questions.length > 0 && + status === "idle" && + questionStartTime === null + ) { + dispatch(startQuiz()); + } + }, [questions.length, status, questionStartTime, dispatch]); + + const selectAnswerHandler = useCallback( + (answerId: string) => { + if (status !== "active" || isSubmitting) { + return; + } + dispatch(selectAnswer(answerId)); + }, + [dispatch, status, isSubmitting], + ); + + const submitAnswerHandler = useCallback(async () => { + if (!currentQuestion || !selectedAnswerId || isSubmitting) { + return; + } + + const userId = getUserIdFromToken(); + if (!userId) { + throw new Error("User not authenticated"); + } + + // Calculate time spent + const timeSpent = questionStartTime + ? Math.floor((Date.now() - questionStartTime) / 1000) + : 0; + + await dispatch( + submitAnswerThunk({ + userId, + puzzleId: currentQuestion.id, + categoryId: currentQuestion.categoryId, + userAnswer: selectedAnswerId, + timeSpent, + }), + ).unwrap(); + }, [ + currentQuestion, + selectedAnswerId, + isSubmitting, + questionStartTime, + dispatch, + ]); + + const goToNextQuestion = useCallback(() => { + if (status === "submitting" || status === "active") { + dispatch(nextQuestion()); + } + }, [dispatch, status]); + + return { + questions, + currentQuestionIndex: currentIndex, + currentQuestion, + selectedAnswerId, + isLoading, + isSubmitting, + error, + isFinished, + submissionResult, + score, + correctAnswersCount, + selectAnswer: selectAnswerHandler, + submitAnswer: submitAnswerHandler, + goToNextQuestion, + }; +} diff --git a/frontend/lib/api/quizApi.ts b/frontend/lib/api/quizApi.ts new file mode 100644 index 0000000..57b0297 --- /dev/null +++ b/frontend/lib/api/quizApi.ts @@ -0,0 +1,148 @@ +export interface PuzzleResponseDto { + id: string; + question: string; + options: string[]; + difficulty: string; + categoryId: string; + points: number; + timeLimit: number; + isCompleted: boolean; +} + +export interface DailyQuestResponseDto { + id: number; + questDate: string; + totalQuestions: number; + completedQuestions: number; + isCompleted: boolean; + pointsEarned: number; + createdAt: string; + completedAt?: string | null; + puzzles: PuzzleResponseDto[]; +} + +export interface SubmitAnswerRequestDto { + userId: string; + puzzleId: string; + categoryId: string; + userAnswer: string; + timeSpent: number; +} + +export interface SubmitAnswerResponseDto { + isCorrect: boolean; + pointsEarned: number; +} + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ""; + +function getAuthHeaders(): Record { + if (typeof window === "undefined") { + return {}; + } + + const token = window.localStorage.getItem("accessToken"); + + if (!token) { + return {}; + } + + return { + Authorization: `Bearer ${token}`, + }; +} + +async function handleResponse(response: Response): Promise { + const contentType = response.headers.get("Content-Type"); + const isJson = contentType && contentType.includes("application/json"); + + const data = isJson ? await response.json() : null; + + if (!response.ok) { + const message = + (data && (data.message as string | undefined)) || + `Request failed with status ${response.status}`; + throw new Error(message); + } + + return data as T; +} + +export async function fetchDailyQuest(): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + ...getAuthHeaders(), + }; + const response = await fetch(`${API_BASE_URL}/puzzles/daily-quest`, { + method: "GET", + headers, + }); + + return handleResponse(response); +} + +export interface FetchPuzzlesParams { + categoryId: string; + difficulty?: string; +} + +export async function fetchPuzzles( + params: FetchPuzzlesParams, +): Promise { + const searchParams = new URLSearchParams(); + searchParams.set("categoryId", params.categoryId); + if (params.difficulty) { + searchParams.set("difficulty", params.difficulty); + } + + const headers: HeadersInit = { + "Content-Type": "application/json", + ...getAuthHeaders(), + }; + const response = await fetch( + `${API_BASE_URL}/puzzles?${searchParams.toString()}`, + { + method: "GET", + headers, + }, + ); + + return handleResponse(response); +} + +export async function submitAnswer( + payload: SubmitAnswerRequestDto, +): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + ...getAuthHeaders(), + }; + const response = await fetch(`${API_BASE_URL}/puzzles/submit`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + + // The backend likely returns a ProgressCalculationResult with validation info. + interface RawResponse { + validation?: { + isCorrect: boolean; + pointsEarned: number; + }; + isCorrect?: boolean; + pointsEarned?: number; + } + const raw = await handleResponse(response); + + const validation = raw?.validation ?? raw; + + return { + isCorrect: + typeof validation?.isCorrect === "boolean" ? validation.isCorrect : false, + pointsEarned: + typeof validation?.pointsEarned === "number" + ? validation.pointsEarned + : 0, + }; +} + diff --git a/frontend/lib/features/quiz/quizSlice.ts b/frontend/lib/features/quiz/quizSlice.ts index 43e75e0..c7e44dd 100644 --- a/frontend/lib/features/quiz/quizSlice.ts +++ b/frontend/lib/features/quiz/quizSlice.ts @@ -1,23 +1,43 @@ -import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit"; +import { + fetchDailyQuest, + fetchPuzzles, + submitAnswer as submitAnswerApi, + type PuzzleResponseDto, + type SubmitAnswerRequestDto, +} from "../../api/quizApi"; export interface Question { id: string; text: string; options: string[]; - correctAnswer: string; + correctAnswer?: string; // May be undefined until submission points: number; + categoryId: string; + difficulty?: string; + timeLimit?: number; + isCompleted?: boolean; +} + +interface SubmissionResult { + isCorrect: boolean; + pointsEarned: number; } interface QuizState { questions: Question[]; currentIndex: number; answers: Record; + selectedAnswerId: string | null; + isSubmitting: boolean; + submissionResult: SubmissionResult | null; questionStartTime: number | null; totalSessionTime: number; score: number; - status: 'idle' | 'loading' | 'active' | 'submitting' | 'completed' | 'error'; + correctAnswersCount: number; + status: "idle" | "loading" | "active" | "submitting" | "completed" | "error"; error: string | null; } @@ -25,53 +45,111 @@ const initialState: QuizState = { questions: [], currentIndex: 0, answers: {}, + selectedAnswerId: null, + isSubmitting: false, + submissionResult: null, questionStartTime: null, totalSessionTime: 0, score: 0, - status: 'idle', + correctAnswersCount: 0, + status: "idle", error: null, }; +export type FetchQuestionsParams = + | { type: "daily-quest" } + | { type: "category"; categoryId: string; difficulty?: string }; + +function mapPuzzleToQuestion(puzzle: PuzzleResponseDto): Question { + return { + id: puzzle.id, + text: puzzle.question, + options: puzzle.options, + points: puzzle.points, + categoryId: puzzle.categoryId, + difficulty: puzzle.difficulty, + timeLimit: puzzle.timeLimit, + isCompleted: puzzle.isCompleted, + // correctAnswer will be set after submission + }; +} + export const fetchQuestions = createAsyncThunk( - 'quiz/fetchQuestions', - async (category: string) => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - - return [ - { - id: 'q1', - text: 'What is 2+2?', - options: ['3', '4', '5'], - correctAnswer: '4', - points: 10, - }, - { - id: 'q2', - text: 'What is Next.js?', - options: ['DB', 'Framework', 'Language'], - correctAnswer: 'Framework', - points: 20, - }, - ] as Question[]; - console.log(category) + "quiz/fetchQuestions", + async (params: FetchQuestionsParams) => { + let puzzles: PuzzleResponseDto[]; + + if (params.type === "daily-quest") { + const quest = await fetchDailyQuest(); + puzzles = quest.puzzles; + } else { + puzzles = await fetchPuzzles({ + categoryId: params.categoryId, + difficulty: params.difficulty, + }); + } + + return puzzles.map(mapPuzzleToQuestion); + }, +); + +export const submitAnswerThunk = createAsyncThunk( + "quiz/submitAnswer", + async ( + payload: { + userId: string; + puzzleId: string; + categoryId: string; + userAnswer: string; + timeSpent: number; + }, + { getState }, + ) => { + const state = getState() as { quiz: QuizState }; + const currentQuestion = state.quiz.questions[state.quiz.currentIndex]; + + if (!currentQuestion) { + throw new Error("No current question"); + } + + const submitPayload: SubmitAnswerRequestDto = { + userId: payload.userId, + puzzleId: payload.puzzleId, + categoryId: payload.categoryId, + userAnswer: payload.userAnswer, + timeSpent: payload.timeSpent, + }; + + const result = await submitAnswerApi(submitPayload); + + return { + ...result, + puzzleId: payload.puzzleId, + }; }, ); const quizSlice = createSlice({ - name: 'quiz', + name: "quiz", initialState, reducers: { startQuiz: (state) => { - state.status = 'active'; + state.status = "active"; state.currentIndex = 0; state.score = 0; + state.correctAnswersCount = 0; state.totalSessionTime = 0; state.answers = {}; + state.selectedAnswerId = null; + state.submissionResult = null; state.questionStartTime = Date.now(); }, + selectAnswer: (state, action: PayloadAction) => { + state.selectedAnswerId = action.payload; + }, submitAnswer: (state, action: PayloadAction) => { const currentQ = state.questions[state.currentIndex]; - if (!currentQ || state.status !== 'active') return; + if (!currentQ || state.status !== "active") return; const now = Date.now(); const duration = state.questionStartTime @@ -85,41 +163,83 @@ const quizSlice = createSlice({ state.score += currentQ.points; } - state.status = 'submitting'; + state.status = "submitting"; state.questionStartTime = null; }, nextQuestion: (state) => { if (state.currentIndex < state.questions.length - 1) { state.currentIndex++; - state.status = 'active'; + state.status = "active"; + state.selectedAnswerId = null; + state.submissionResult = null; state.questionStartTime = Date.now(); } else { - state.status = 'completed'; + state.status = "completed"; state.questionStartTime = null; } }, exitQuiz: (state) => { - state.status = 'idle'; + state.status = "idle"; state.questions = []; state.answers = {}; + state.selectedAnswerId = null; + state.submissionResult = null; }, }, extraReducers: (builder) => { builder .addCase(fetchQuestions.pending, (state) => { - state.status = 'loading'; + state.status = "loading"; + state.error = null; }) .addCase(fetchQuestions.fulfilled, (state, action) => { - state.status = 'idle'; + state.status = "idle"; state.questions = action.payload; + state.currentIndex = 0; + state.error = null; }) .addCase(fetchQuestions.rejected, (state, action) => { - state.status = 'error'; - state.error = action.error.message || 'Error'; + state.status = "error"; + state.error = action.error.message || "Failed to fetch questions"; + }) + .addCase(submitAnswerThunk.pending, (state) => { + state.isSubmitting = true; + state.error = null; + }) + .addCase(submitAnswerThunk.fulfilled, (state, action) => { + state.isSubmitting = false; + state.submissionResult = { + isCorrect: action.payload.isCorrect, + pointsEarned: action.payload.pointsEarned, + }; + + // Update score and correct answers count if correct + if (action.payload.isCorrect) { + const currentQ = state.questions[state.currentIndex]; + if (currentQ) { + state.score += action.payload.pointsEarned; + state.correctAnswersCount += 1; + } + } + + // Store the answer + const currentQ = state.questions[state.currentIndex]; + if (currentQ && state.selectedAnswerId) { + state.answers[currentQ.id] = state.selectedAnswerId; + } + }) + .addCase(submitAnswerThunk.rejected, (state, action) => { + state.isSubmitting = false; + state.error = action.error.message || "Failed to submit answer"; }); }, }); -export const { startQuiz, submitAnswer, nextQuestion, exitQuiz } = - quizSlice.actions; +export const { + startQuiz, + submitAnswer, + nextQuestion, + exitQuiz, + selectAnswer, +} = quizSlice.actions; export default quizSlice.reducer;