user tests
This commit is contained in:
parent
2b714e3a78
commit
01446071e5
6
src/api/categoryApi.ts
Normal file
6
src/api/categoryApi.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import axiosInstance from "./axiosInstance";
|
||||
|
||||
export const getCategories = async () => {
|
||||
const res = await axiosInstance.get("/api/categories");
|
||||
return res.data.data;
|
||||
};
|
||||
25
src/api/questionsApi.ts
Normal file
25
src/api/questionsApi.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import axiosInstance from "./axiosInstance";
|
||||
|
||||
|
||||
|
||||
type GetQuestionsParams = {
|
||||
page?: number;
|
||||
id?: number;
|
||||
};
|
||||
|
||||
export const getQuestions = async ({ page = 1, id }: GetQuestionsParams) => {
|
||||
const params: Record<string, any> = { page };
|
||||
if (id) params.category_id = id;
|
||||
|
||||
const { data } = await axiosInstance.get(
|
||||
"/api/questions",
|
||||
{ params }
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getQuestionById = async (id: number) => {
|
||||
const response = await axiosInstance.get(`/api/questions/${id}`);
|
||||
return response.data.data;
|
||||
};
|
||||
41
src/api/testApi.ts
Normal file
41
src/api/testApi.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type {
|
||||
StartTestPayload,
|
||||
SubmitAnswerPayload,
|
||||
UserTestType,
|
||||
} from "../components/shared/types/TestTypes";
|
||||
import axiosInstance from "./axiosInstance";
|
||||
|
||||
export const startTest = async (data: StartTestPayload) => {
|
||||
const res = await axiosInstance.post<{ data: UserTestType }>(
|
||||
"/api/user-tests",
|
||||
data
|
||||
);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const completeUserTest = async (userTestId: number) => {
|
||||
const res = await axiosInstance.post<{ message: string }>(
|
||||
`/api/user-tests/${userTestId}/complete`
|
||||
);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const getUserTestById = async (userTestId: number) => {
|
||||
const res = await axiosInstance.get<{ data: UserTestType }>(
|
||||
`/api/user-tests/${userTestId}`
|
||||
);
|
||||
return res.data.data;
|
||||
};
|
||||
|
||||
export const getUserTests = async () => {
|
||||
const res = await axiosInstance.get<UserTestType[]>("/api/user-tests/me");
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const submitAnswer = async (data: SubmitAnswerPayload) => {
|
||||
const res = await axiosInstance.post<{ message: string }>(
|
||||
`/api/user-test-answers/${data.answerId}/submit`,
|
||||
{ answer: data.answer }
|
||||
);
|
||||
return res.data;
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 310 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 422 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB |
81
src/components/Answers/Answers.tsx
Normal file
81
src/components/Answers/Answers.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
|
||||
interface Variant {
|
||||
id: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface LearningAnswersProps {
|
||||
variants?: Variant[];
|
||||
correctAnswers: number[] | string[];
|
||||
type: "single" | "text";
|
||||
}
|
||||
|
||||
const LearningAnswers = ({
|
||||
variants = [],
|
||||
correctAnswers = [],
|
||||
type,
|
||||
}: LearningAnswersProps) => {
|
||||
const [showAnswer, setShowAnswer] = useState(false);
|
||||
|
||||
if (type === "text") {
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{showAnswer && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="success.main"
|
||||
sx={{ mt: 1, p: 1, border: "1px solid #ccc", borderRadius: 2 }}
|
||||
>
|
||||
Correct answer:{" "}
|
||||
{Array.isArray(correctAnswers)
|
||||
? correctAnswers.join(", ")
|
||||
: correctAnswers}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={() => setShowAnswer(true)}
|
||||
>
|
||||
Reveal Answer
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{variants.map((variant) => (
|
||||
<Box
|
||||
key={variant.id}
|
||||
sx={{
|
||||
p: 2,
|
||||
my: 1,
|
||||
borderRadius: 2,
|
||||
border: "1px solid #ccc",
|
||||
backgroundColor:
|
||||
showAnswer && (correctAnswers as number[]).includes(variant.id)
|
||||
? "success.light"
|
||||
: "background.paper",
|
||||
}}
|
||||
>
|
||||
<Typography>{variant.text}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 2 }}
|
||||
onClick={() => setShowAnswer(true)}
|
||||
>
|
||||
Reveal Answer
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LearningAnswers;
|
||||
@ -1,12 +1,16 @@
|
||||
import styled from "@emotion/styled";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const QuestionWrapper = styled("div")({
|
||||
export const QuestionWrapper = styled(Link)({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
padding: "15px",
|
||||
border: "3px solid #4B2981",
|
||||
borderRadius: "10px",
|
||||
marginBottom: "10px",
|
||||
textDecoration: "none",
|
||||
color: "inherit"
|
||||
});
|
||||
|
||||
export const QuestionTitle = styled("div")({
|
||||
|
||||
@ -7,22 +7,15 @@ import {
|
||||
} from "./Question.styles";
|
||||
import CategoryIcon from "@mui/icons-material/Category";
|
||||
import PersonIcon from "@mui/icons-material/Person";
|
||||
import type { QuestionType } from "../shared/types/QuestionTypes";
|
||||
|
||||
type Question = {
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
difficulty: number;
|
||||
author: string;
|
||||
};
|
||||
|
||||
type QuestionProps = {
|
||||
question: Question;
|
||||
};
|
||||
interface QuestionProps {
|
||||
question: QuestionType;
|
||||
}
|
||||
|
||||
const Question = ({ question }: QuestionProps) => {
|
||||
return (
|
||||
<QuestionWrapper>
|
||||
<QuestionWrapper to={`/questions/${question.id}`}>
|
||||
<QuestionTitle>
|
||||
<Typography sx={{ mr: "10px" }} variant="h5">
|
||||
{question.title}
|
||||
@ -31,17 +24,20 @@ const Question = ({ question }: QuestionProps) => {
|
||||
variant="outlined"
|
||||
icon={<CategoryIcon />}
|
||||
color="primary"
|
||||
label={`${question.category}`}
|
||||
label={question.category.name}
|
||||
/>
|
||||
</QuestionTitle>
|
||||
|
||||
<Typography variant="body1">{question.description}</Typography>
|
||||
|
||||
<QuestionMetadata>
|
||||
<Typography variant="body1">
|
||||
Difficulty: {question.difficulty}
|
||||
</Typography>
|
||||
|
||||
<AuthorMeta>
|
||||
<PersonIcon />
|
||||
<Typography variant="body1">{question.author}</Typography>
|
||||
<Typography variant="body1">{question.author.username}</Typography>
|
||||
</AuthorMeta>
|
||||
</QuestionMetadata>
|
||||
</QuestionWrapper>
|
||||
|
||||
@ -9,30 +9,67 @@ import {
|
||||
} from "@mui/material";
|
||||
import { StartTestWrapper } from "./StartTestForm.styles";
|
||||
import { useState } from "react";
|
||||
import { notifyError, notifySuccess } from "../shared/toastify";
|
||||
import { useCategories } from "../../hooks/Question/useCategories";
|
||||
import type { Category } from "../shared/types/QuestionTypes";
|
||||
import { useStartTest } from "../../hooks/Tests/useStartTest";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
const StartTestForm = () => {
|
||||
const [category, setCategory] = useState("");
|
||||
const [minDifficulty, setMinDifficulty] = useState(1);
|
||||
const [maxDifficulty, setMaxDifficulty] = useState(10);
|
||||
const { data: categories, isLoading, isError } = useCategories();
|
||||
const startTestMutation = useStartTest();
|
||||
|
||||
const handleStartTest = () => {
|
||||
if (!category) {
|
||||
toast.error("Please select a category!");
|
||||
return;
|
||||
}
|
||||
|
||||
startTestMutation.mutate({
|
||||
category_id: Number(category),
|
||||
min_difficulty: minDifficulty,
|
||||
max_difficulty: maxDifficulty,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFeelingLucky = () => {
|
||||
if (!categories || categories.length === 0) {
|
||||
toast.error("No categories available");
|
||||
return;
|
||||
}
|
||||
|
||||
const randomCategory =
|
||||
categories[Math.floor(Math.random() * categories.length)];
|
||||
|
||||
const minDifficulty = 1;
|
||||
const maxDifficulty = 10;
|
||||
|
||||
startTestMutation.mutate({
|
||||
category_id: randomCategory.id,
|
||||
min_difficulty: minDifficulty,
|
||||
max_difficulty: maxDifficulty,
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeMinDifficulty = (e: SelectChangeEvent<number>) => {
|
||||
const value = Number(e.target.value);
|
||||
if (value <= maxDifficulty) {
|
||||
setMinDifficulty(value);
|
||||
} else {
|
||||
notifyError("Min difficulty should be smaller than max difficulty!")
|
||||
}
|
||||
toast.error("Min difficulty should be smaller than max difficulty!");
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeMaxDifficulty = (e: SelectChangeEvent<number>) => {
|
||||
const value = Number(e.target.value);
|
||||
if (value >= minDifficulty) {
|
||||
setMaxDifficulty(value);
|
||||
} else {
|
||||
notifyError("Max difficulty should be bigger than min difficulty!")
|
||||
}
|
||||
toast.error("Max difficulty should be bigger than min difficulty!");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<StartTestWrapper>
|
||||
<FormControl sx={{ minWidth: "300px" }} variant="filled">
|
||||
@ -42,11 +79,15 @@ const StartTestForm = () => {
|
||||
id="category-select"
|
||||
value={category}
|
||||
label="Category"
|
||||
onChange={(e) => setCategory(e.target.value as string)}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<MenuItem value={10}>Physics</MenuItem>
|
||||
<MenuItem value={20}>Microcontrollers</MenuItem>
|
||||
<MenuItem value={30}>English</MenuItem>
|
||||
{isLoading && <MenuItem disabled>Loading...</MenuItem>}
|
||||
{isError && <MenuItem disabled>Error loading categories</MenuItem>}
|
||||
{categories?.map((cat: Category) => (
|
||||
<MenuItem key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl sx={{ minWidth: "200px" }} variant="filled">
|
||||
@ -83,10 +124,16 @@ const StartTestForm = () => {
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Tooltip title="Start a test based on selected parameters.">
|
||||
<Button onClick={() => notifySuccess("Started")} variant="contained">Start Test</Button>
|
||||
<Button onClick={handleStartTest} variant="contained">
|
||||
Start Test
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Random category and difficulty test.">
|
||||
<Button sx={{ backgroundColor: "#6610F2" }} variant="contained">
|
||||
<Button
|
||||
onClick={handleFeelingLucky}
|
||||
sx={{ backgroundColor: "#6610F2" }}
|
||||
variant="contained"
|
||||
>
|
||||
Im Feeling Lucky
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
98
src/components/TestQuestion/TestQuestion.tsx
Normal file
98
src/components/TestQuestion/TestQuestion.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Button,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import type { UserTestAnswer } from "../shared/types/TestTypes";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface TestQuestionProps {
|
||||
qa: UserTestAnswer;
|
||||
onSubmit: (answer: (string | number)[]) => void;
|
||||
}
|
||||
|
||||
const TestQuestion = ({ qa, onSubmit }: TestQuestionProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { question, answer: initialAnswer } = qa;
|
||||
const [selectedAnswers, setSelectedAnswers] = useState<(string | number)[]>(
|
||||
initialAnswer ?? []
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAnswers(initialAnswer ?? []);
|
||||
queryClient.invalidateQueries({ queryKey: ["current-test"] });
|
||||
}, [initialAnswer, qa.question.id]);
|
||||
|
||||
const handleCheckboxChange = (variantId: number) => {
|
||||
if (question.type === "single") {
|
||||
setSelectedAnswers([variantId]);
|
||||
} else {
|
||||
setSelectedAnswers((prev) =>
|
||||
prev.includes(variantId)
|
||||
? prev.filter((x) => x !== variantId)
|
||||
: [...prev, variantId]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (value: string) => {
|
||||
setSelectedAnswers([value]);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(selectedAnswers);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
mb: 3,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">{question.title}</Typography>
|
||||
{question.description && (
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{question.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{question.type === "text" && (
|
||||
<TextField
|
||||
fullWidth
|
||||
value={selectedAnswers[0] ?? ""}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(question.type === "single" || question.type === "multiple") &&
|
||||
question.variants.map((variant) => (
|
||||
<FormControlLabel
|
||||
key={variant.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedAnswers.includes(variant.id)}
|
||||
onChange={() => handleCheckboxChange(variant.id)}
|
||||
/>
|
||||
}
|
||||
label={variant.text}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button variant="contained" sx={{ mt: 2 }} onClick={handleSubmit}>
|
||||
Submit Answer
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestQuestion;
|
||||
@ -1,4 +0,0 @@
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const notifySuccess = (msg: string) => toast.success(msg);
|
||||
export const notifyError = (msg: string) => toast.error(msg);
|
||||
26
src/components/shared/types/QuestionTypes.ts
Normal file
26
src/components/shared/types/QuestionTypes.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export type Variant = {
|
||||
id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type Author = {
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type QuestionType = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
type: "single" | "multiple" | "text";
|
||||
difficulty: number;
|
||||
variants: Variant[];
|
||||
correct_answers: number[] | string[];
|
||||
category: Category;
|
||||
author: Author;
|
||||
};
|
||||
63
src/components/shared/types/TestTypes.ts
Normal file
63
src/components/shared/types/TestTypes.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import type { User } from "./AuthTypes";
|
||||
import type { QuestionType } from "./QuestionTypes";
|
||||
|
||||
export type CategoryType = {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at?: string;
|
||||
questions_count?: number;
|
||||
user_tests_count?: number;
|
||||
};
|
||||
|
||||
export interface StartTestPayload {
|
||||
category_id: number;
|
||||
min_difficulty: number;
|
||||
max_difficulty: number;
|
||||
}
|
||||
|
||||
export type UserTestAnswer = {
|
||||
id: number;
|
||||
user_test_id: number;
|
||||
question_id: number;
|
||||
question: QuestionType;
|
||||
answer: (string | number)[] | null;
|
||||
user_id: number;
|
||||
is_correct: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TestType = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
category_id: number;
|
||||
category: CategoryType;
|
||||
questions: QuestionType[];
|
||||
is_available: boolean;
|
||||
author_id: number;
|
||||
author: User;
|
||||
closed_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UserTestType = {
|
||||
id: number;
|
||||
test_id: number | null;
|
||||
test?: TestType;
|
||||
user_id: number;
|
||||
user?: User;
|
||||
closed_at: string | null;
|
||||
is_completed?: boolean;
|
||||
score?: number;
|
||||
is_available: boolean;
|
||||
answers?: UserTestAnswer[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type SubmitAnswerPayload = {
|
||||
answerId: number;
|
||||
answer: (string | number)[];
|
||||
};
|
||||
@ -1,7 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { useCurrentUser } from "../hooks/useCurrentUser";
|
||||
import { useLogin } from "../hooks/useLogin";
|
||||
import { useCurrentUser } from "../hooks/auth/useCurrentUser";
|
||||
import { useLogin } from "../hooks/auth/useLogin";
|
||||
import type {
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
|
||||
8
src/hooks/Question/useCategories.ts
Normal file
8
src/hooks/Question/useCategories.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCategories } from "../../api/categoryApi";
|
||||
|
||||
export const useCategories = () =>
|
||||
useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: getCategories,
|
||||
});
|
||||
9
src/hooks/Question/useQuestionById.ts
Normal file
9
src/hooks/Question/useQuestionById.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getQuestionById } from "../../api/questionsApi";
|
||||
|
||||
export const useQuestionById = (id?: number) => {
|
||||
return useQuery({
|
||||
queryKey: ["single-question", id],
|
||||
queryFn: () => getQuestionById(Number(id)),
|
||||
});
|
||||
};
|
||||
15
src/hooks/Question/useQuestions.ts
Normal file
15
src/hooks/Question/useQuestions.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getQuestions } from "../../api/questionsApi";
|
||||
|
||||
type UseQuestionsParams = {
|
||||
page?: number;
|
||||
id?: number;
|
||||
};
|
||||
|
||||
export const useQuestions = ({ page = 1, id }: UseQuestionsParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["questions", { page, id }],
|
||||
queryFn: () => getQuestions({ page, id }),
|
||||
staleTime: 1000 * 60,
|
||||
});
|
||||
};
|
||||
15
src/hooks/Tests/useCompleteTest.ts
Normal file
15
src/hooks/Tests/useCompleteTest.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { completeUserTest } from "../../api/testApi";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const useCompleteTest = () => {
|
||||
return useMutation({
|
||||
mutationFn: completeUserTest,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
9
src/hooks/Tests/useGetUserTests.ts
Normal file
9
src/hooks/Tests/useGetUserTests.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getUserTests } from "../../api/testApi";
|
||||
|
||||
export const useGetUserTests = () => {
|
||||
return useQuery({
|
||||
queryKey: ["user-tests"],
|
||||
queryFn: () => getUserTests,
|
||||
});
|
||||
};
|
||||
18
src/hooks/Tests/useStartTest.ts
Normal file
18
src/hooks/Tests/useStartTest.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { startTest } from "../../api/testApi";
|
||||
import { toast } from "react-toastify";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const useStartTest = () => {
|
||||
const navigate = useNavigate();
|
||||
return useMutation({
|
||||
mutationFn: startTest,
|
||||
onSuccess: (data) => {
|
||||
toast.success("Test Started");
|
||||
navigate(`/tests/${data.id}`);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
16
src/hooks/Tests/useSubmitAnswer.ts
Normal file
16
src/hooks/Tests/useSubmitAnswer.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { submitAnswer } from "../../api/testApi";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const useSubmitAnswer = () => {
|
||||
return useMutation({
|
||||
mutationFn: submitAnswer,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
console.log(data);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || "Something went wrong");
|
||||
},
|
||||
});
|
||||
};
|
||||
9
src/hooks/Tests/useUserTestById.ts
Normal file
9
src/hooks/Tests/useUserTestById.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getUserTestById } from "../../api/testApi";
|
||||
|
||||
export const useUserTestById = (id: number) => {
|
||||
return useQuery({
|
||||
queryKey: ["current-test"],
|
||||
queryFn: () => getUserTestById(id),
|
||||
});
|
||||
};
|
||||
@ -1,12 +1,12 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { activateAccount } from "../api/authApi";
|
||||
import { activateAccount } from "../../api/authApi";
|
||||
import { toast } from "react-toastify";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const useActivateAccount = () => {
|
||||
const navigate = useNavigate();
|
||||
return useMutation({
|
||||
mutationFn: (token: string) => activateAccount(token),
|
||||
mutationFn: activateAccount,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
navigate("/login");
|
||||
@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchMe } from "../api/authApi";
|
||||
import { fetchMe } from "../../api/authApi";
|
||||
|
||||
export const useCurrentUser = () =>
|
||||
useQuery({
|
||||
@ -1,13 +1,12 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { loginRequest } from "../api/authApi";
|
||||
import { loginRequest } from "../../api/authApi";
|
||||
import { toast } from "react-toastify";
|
||||
import type { LoginPayload } from "../components/shared/types/AuthTypes";
|
||||
|
||||
export const useLogin = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: LoginPayload) => loginRequest(payload),
|
||||
mutationFn: loginRequest,
|
||||
|
||||
onSuccess: (data) => {
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
@ -1,19 +1,12 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { registrationRequest } from "../api/authApi";
|
||||
import type { RegistrationPayload } from "../components/shared/types/AuthTypes";
|
||||
import { registrationRequest } from "../../api/authApi";
|
||||
import { toast } from "react-toastify";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const useRegistration = () => {
|
||||
const navigate = useNavigate();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
password_confirmation,
|
||||
}: RegistrationPayload) =>
|
||||
registrationRequest({ username, email, password, password_confirmation }),
|
||||
mutationFn: registrationRequest,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
navigate("/login");
|
||||
@ -1,10 +1,10 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { requestPasswordReset } from "../api/authApi";
|
||||
import { requestPasswordReset } from "../../api/authApi";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const useRequestPasswordReset = () => {
|
||||
return useMutation({
|
||||
mutationFn: (email: string) => requestPasswordReset(email),
|
||||
mutationFn: requestPasswordReset,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
},
|
||||
@ -1,11 +1,10 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { ResetPasswordPayload } from "../components/shared/types/AuthTypes";
|
||||
import { resetPassword } from "../api/authApi";
|
||||
import { resetPassword } from "../../api/authApi";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export const useResetPassword = () => {
|
||||
return useMutation({
|
||||
mutationFn: (data: ResetPasswordPayload) => resetPassword(data),
|
||||
mutationFn: resetPassword,
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
},
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useActivateAccount } from "../../hooks/useActivateAccount";
|
||||
import { useActivateAccount } from "../../hooks/auth/useActivateAccount";
|
||||
import { CircularProgress, Box, Typography } from "@mui/material";
|
||||
|
||||
const ActivateAccountPage = () => {
|
||||
|
||||
@ -7,11 +7,15 @@ export const WelcomeContainer = styled("div")({
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "10px",
|
||||
marginBottom: "36px"
|
||||
marginBottom: "36px",
|
||||
});
|
||||
|
||||
export const ButtonGroup = styled("div")({
|
||||
display: "flex",
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10
|
||||
});
|
||||
|
||||
export const IndexWrapper = styled("div")({
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import { Typography, Pagination } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import Container from "../../components/shared/Container";
|
||||
import { WelcomeContainer } from "./IndexPage.styles";
|
||||
import StartTestForm from "../../components/StartTestForm/StartTestForm";
|
||||
import Question from "../../components/Question/Question";
|
||||
import { useQuestions } from "../../hooks/Question/useQuestions";
|
||||
import type { QuestionType } from "../../components/shared/types/QuestionTypes";
|
||||
|
||||
const IndexPage = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const questionsQuery = useQuestions({ page });
|
||||
|
||||
const myQuestion = {
|
||||
title: "Example Question",
|
||||
category: "Math",
|
||||
description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed ultricies, urna eu aliquet tincidunt, augue turpis gravida risus, sit amet posuere neque tellus sit amet justo. Curabitur non facilisis orci, vitae eleifend est. Cras fermentum, velit at scelerisque varius, mauris justo cursus lacus, ac porttitor ante metus sit amet nibh. Vivamus imperdiet, diam vel efficitur euismod, urna odio pharetra ipsum, vitae sollicitudin neque enim ut tortor. Donec gravida orci.",
|
||||
difficulty: 3,
|
||||
author: "David"
|
||||
};
|
||||
const questions = questionsQuery.data?.data ?? [];
|
||||
const loading = questionsQuery.isLoading;
|
||||
const error = questionsQuery.error?.message ?? null;
|
||||
const meta = questionsQuery.data?.meta ?? null;
|
||||
|
||||
|
||||
return (
|
||||
@ -21,9 +23,30 @@ const myQuestion = {
|
||||
<Typography variant="h2">Welcome To HoshiAI!</Typography>
|
||||
<Typography variant="subtitle1">The best place to learn!</Typography>
|
||||
</WelcomeContainer>
|
||||
|
||||
<StartTestForm />
|
||||
<Typography sx={{mb:"36px"}} variant="h3">Browse Questions</Typography>
|
||||
<Question question={myQuestion} />
|
||||
|
||||
<Typography sx={{ mb: "36px" }} variant="h3">
|
||||
Browse Questions
|
||||
</Typography>
|
||||
|
||||
{loading && <Typography>Loading questions...</Typography>}
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
|
||||
{questions.map((q: QuestionType) => (
|
||||
<Question key={q.id} question={q} />
|
||||
))}
|
||||
|
||||
{meta && meta.last_page > 1 && (
|
||||
<Pagination
|
||||
count={meta.last_page}
|
||||
page={page}
|
||||
onChange={(_, value) => setPage(value)}
|
||||
color="primary"
|
||||
shape="rounded"
|
||||
sx={{ mt: 3, mb: 3, display: "flex", justifyContent: "center" }}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@ -3,7 +3,7 @@ import { useAuth } from "../../context/AuthContext";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Container from "../../components/shared/Container";
|
||||
import { Box, Button, TextField, Typography } from "@mui/material";
|
||||
import { useRegistration } from "../../hooks/useRegistration";
|
||||
import { useRegistration } from "../../hooks/auth/useRegistration";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
const RegistrationPage = () => {
|
||||
|
||||
@ -3,7 +3,7 @@ import Container from "../../components/shared/Container";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRequestPasswordReset } from "../../hooks/useRequestPasswordReset";
|
||||
import { useRequestPasswordReset } from "../../hooks/auth/useRequestPasswordReset";
|
||||
|
||||
const RequestResetPage = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { toast } from "react-toastify";
|
||||
import { useResetPassword } from "../../hooks/useResetPassword";
|
||||
import { useResetPassword } from "../../hooks/auth/useResetPassword";
|
||||
import Container from "../../components/shared/Container";
|
||||
import { Box, Button, TextField, Typography } from "@mui/material";
|
||||
|
||||
|
||||
28
src/pages/SingleQuestionPage/SingleQuestionPage.tsx
Normal file
28
src/pages/SingleQuestionPage/SingleQuestionPage.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Container } from "@mui/material";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuestionById } from "../../hooks/Question/useQuestionById";
|
||||
import Question from "../../components/Question/Question";
|
||||
import LearningAnswers from "../../components/Answers/Answers";
|
||||
|
||||
const SingleQuestionPage = () => {
|
||||
const { id } = useParams();
|
||||
const { data: question, isLoading, error } = useQuestionById(Number(id));
|
||||
|
||||
if (isLoading) return <Container>Loading...</Container>;
|
||||
if (error) return <Container>Error: {error.message}</Container>;
|
||||
if (!question) return <Container>No question found</Container>;
|
||||
|
||||
return (
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Question question={question} />
|
||||
|
||||
<LearningAnswers
|
||||
variants={question.variants}
|
||||
correctAnswers={question.correct_answers}
|
||||
type={question.type as "single" | "text"}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleQuestionPage;
|
||||
136
src/pages/TestPage/TestPage.tsx
Normal file
136
src/pages/TestPage/TestPage.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import Container from "../../components/shared/Container";
|
||||
import { useUserTestById } from "../../hooks/Tests/useUserTestById";
|
||||
import { useState } from "react";
|
||||
import { Box, Button, Pagination, Typography } from "@mui/material";
|
||||
import TestQuestion from "../../components/TestQuestion/TestQuestion";
|
||||
import { useSubmitAnswer } from "../../hooks/Tests/useSubmitAnswer";
|
||||
import { useCompleteTest } from "../../hooks/Tests/useCompleteTest";
|
||||
|
||||
export const TestPage = () => {
|
||||
const { id } = useParams();
|
||||
const { data: test, isLoading, error } = useUserTestById(Number(id));
|
||||
const [currentQuestion, setCurrentQuestion] = useState(1);
|
||||
const submitAnswerMutation = useSubmitAnswer();
|
||||
const completeTestMutation = useCompleteTest();
|
||||
|
||||
const handleCompleteTest = () => {
|
||||
if (test) completeTestMutation.mutate(test.id);
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Loading your test, please wait...
|
||||
</Typography>
|
||||
</Container>
|
||||
);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h6" color="error">
|
||||
Oops! An error occurred: {error.message}
|
||||
</Typography>
|
||||
</Container>
|
||||
);
|
||||
|
||||
if (!test)
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
No test found.
|
||||
</Typography>
|
||||
</Container>
|
||||
);
|
||||
|
||||
if (!test.is_available && !test.is_completed)
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
This test is no longer available.
|
||||
</Typography>
|
||||
</Container>
|
||||
);
|
||||
|
||||
if (test.is_completed)
|
||||
return (
|
||||
<Container>
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
You have already completed this test.
|
||||
</Typography>
|
||||
</Container>
|
||||
);
|
||||
|
||||
const questionsAndAnswers = test.answers;
|
||||
const currentQA = questionsAndAnswers?.[currentQuestion - 1];
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2">{test.test?.title ?? "User Test"}</Typography>
|
||||
<Typography variant="subtitle1">
|
||||
Expires at:{" "}
|
||||
{test.closed_at
|
||||
? new Date(test.closed_at).toLocaleString("en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
: "N/A"}
|
||||
</Typography>
|
||||
</Box>
|
||||
{currentQA ? (
|
||||
<TestQuestion
|
||||
qa={questionsAndAnswers[currentQuestion - 1]}
|
||||
onSubmit={(ans) =>
|
||||
submitAnswerMutation.mutate({
|
||||
answerId: questionsAndAnswers[currentQuestion - 1].id,
|
||||
answer: ans,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Typography>No question found.</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
mt: 2,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Pagination
|
||||
count={questionsAndAnswers?.length}
|
||||
shape="rounded"
|
||||
color="primary"
|
||||
page={currentQuestion}
|
||||
onChange={(_, page) => setCurrentQuestion(page)}
|
||||
/>
|
||||
{currentQuestion == questionsAndAnswers?.length && (
|
||||
<Button
|
||||
onClick={handleCompleteTest}
|
||||
variant="contained"
|
||||
color="success"
|
||||
sx={{ mt: 15, fontSize: "32px" }}
|
||||
>
|
||||
Complete test
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@ -9,6 +9,8 @@ import ActivateAccountPage from "../pages/ActivateAccountPage/ActivateAccountPag
|
||||
import RequestResetPage from "../pages/RequestResetPage/RequestResetPage";
|
||||
import NotFoundPage from "../pages/NotFoundPage/NotFoundPage";
|
||||
import ResetPasswordPage from "../pages/ResetPasswordPage/ResetPasswordPage";
|
||||
import SingleQuestionPage from "../pages/SingleQuestionPage/SingleQuestionPage";
|
||||
import { TestPage } from "../pages/TestPage/TestPage";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@ -47,6 +49,14 @@ const router = createBrowserRouter([
|
||||
path: "/auth/reset-password/:token",
|
||||
element: <ResetPasswordPage />,
|
||||
},
|
||||
{
|
||||
path: "/questions/:id",
|
||||
element: <SingleQuestionPage/>
|
||||
},
|
||||
{
|
||||
path: "/tests/:id",
|
||||
element: <TestPage/>
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <NotFoundPage />,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user