generated from muhtadeetaron/nextjs-template
feat(zustand): add zustand stores for exam, timer and auth
This commit is contained in:
@ -11,10 +11,11 @@ import DestructibleAlert from "@/components/DestructibleAlert";
|
|||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { LoginForm } from "@/types/auth";
|
import { LoginForm } from "@/types/auth";
|
||||||
import { CircleAlert } from "lucide-react";
|
import { CircleAlert } from "lucide-react";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setToken } = useAuth();
|
const { setToken } = useAuthStore();
|
||||||
|
|
||||||
const [form, setForm] = useState<LoginForm>({
|
const [form, setForm] = useState<LoginForm>({
|
||||||
identifier: "",
|
identifier: "",
|
||||||
@ -88,7 +89,7 @@ const LoginPage = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <DestructibleAlert text={error} extraStyles="" />}
|
{error && <DestructibleAlert text={error} />}
|
||||||
|
|
||||||
<h1 className="flex justify-center items-center gap-2 bg-green-200 p-4 rounded-full">
|
<h1 className="flex justify-center items-center gap-2 bg-green-200 p-4 rounded-full">
|
||||||
<CircleAlert size={20} />
|
<CircleAlert size={20} />
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import BackgroundWrapper from "@/components/BackgroundWrapper";
|
|||||||
import FormField from "@/components/FormField";
|
import FormField from "@/components/FormField";
|
||||||
import DestructibleAlert from "@/components/DestructibleAlert";
|
import DestructibleAlert from "@/components/DestructibleAlert";
|
||||||
import { RegisterForm } from "@/types/auth";
|
import { RegisterForm } from "@/types/auth";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
interface ValidationErrorItem {
|
interface ValidationErrorItem {
|
||||||
type: string;
|
type: string;
|
||||||
@ -27,7 +28,7 @@ interface CustomError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const { setToken } = useAuth();
|
const { setToken } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [form, setForm] = useState<RegisterForm>({
|
const [form, setForm] = useState<RegisterForm>({
|
||||||
full_name: "",
|
full_name: "",
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import BackgroundWrapper from "@/components/BackgroundWrapper";
|
|||||||
import { API_URL, getToken } from "@/lib/auth";
|
import { API_URL, getToken } from "@/lib/auth";
|
||||||
import { Loader, RefreshCw } from "lucide-react";
|
import { Loader, RefreshCw } from "lucide-react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
type Mock = {
|
type Mock = {
|
||||||
test_id: string;
|
test_id: string;
|
||||||
@ -22,7 +23,7 @@ type Mock = {
|
|||||||
|
|
||||||
export default function MockScreen() {
|
export default function MockScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
const [mocks, setMocks] = useState<Mock[]>([]);
|
const [mocks, setMocks] = useState<Mock[]>([]);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { API_URL, getToken } from "@/lib/auth";
|
|||||||
import { Loader, RefreshCw } from "lucide-react";
|
import { Loader, RefreshCw } from "lucide-react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { Question } from "@/types/exam";
|
import { Question } from "@/types/exam";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
type Subject = {
|
type Subject = {
|
||||||
subject_id: string;
|
subject_id: string;
|
||||||
@ -19,7 +20,7 @@ type Subject = {
|
|||||||
|
|
||||||
export default function PaperScreen() {
|
export default function PaperScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
@ -70,7 +71,7 @@ export default function PaperScreen() {
|
|||||||
<Header displayTabTitle="Subjects" />
|
<Header displayTabTitle="Subjects" />
|
||||||
<div className="overflow-y-auto">
|
<div className="overflow-y-auto">
|
||||||
<div className="mt-5 px-5">
|
<div className="mt-5 px-5">
|
||||||
<DestructibleAlert text={errorMsg} extraStyles="" />
|
<DestructibleAlert text={errorMsg} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center mt-4">
|
<div className="flex justify-center mt-4">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import BackgroundWrapper from "@/components/BackgroundWrapper";
|
|||||||
import { API_URL, getToken } from "@/lib/auth";
|
import { API_URL, getToken } from "@/lib/auth";
|
||||||
import { Loader, RefreshCw } from "lucide-react";
|
import { Loader, RefreshCw } from "lucide-react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
|
||||||
type Topic = {
|
type Topic = {
|
||||||
topic_id: string;
|
topic_id: string;
|
||||||
@ -21,7 +22,7 @@ type Topic = {
|
|||||||
|
|
||||||
export default function TopicScreen() {
|
export default function TopicScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
const [topics, setTopics] = useState<Topic[]>([]);
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
@ -72,7 +73,7 @@ export default function TopicScreen() {
|
|||||||
<Header displayTabTitle="Subjects" />
|
<Header displayTabTitle="Subjects" />
|
||||||
<div className="overflow-y-auto">
|
<div className="overflow-y-auto">
|
||||||
<div className="mt-5 px-5">
|
<div className="mt-5 px-5">
|
||||||
<DestructibleAlert text={errorMsg} extraStyles="" />
|
<DestructibleAlert text={errorMsg} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center mt-4">
|
<div className="flex justify-center mt-4">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { clearAuthToken } from "@/lib/auth";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import {
|
import {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
ChartColumn,
|
ChartColumn,
|
||||||
@ -23,10 +23,10 @@ import React from "react";
|
|||||||
|
|
||||||
const SettingsPage = () => {
|
const SettingsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, isLoading } = useAuth();
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
clearAuthToken();
|
logout();
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useCallback, useMemo } from "react";
|
import React, { useEffect, useCallback } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTimer } from "@/context/TimerContext";
|
|
||||||
import { useExam } from "@/context/ExamContext";
|
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import { useModal } from "@/context/ModalContext";
|
|
||||||
import Modal from "@/components/ExamModal";
|
|
||||||
import QuestionItem from "@/components/QuestionItem";
|
import QuestionItem from "@/components/QuestionItem";
|
||||||
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||||
|
import { useExamStore } from "@/stores/examStore";
|
||||||
|
import { useTimerStore } from "@/stores/timerStore";
|
||||||
|
|
||||||
export default function ExamPage() {
|
export default function ExamPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -16,21 +14,25 @@ export default function ExamPage() {
|
|||||||
const test_id = searchParams.get("test_id") || "";
|
const test_id = searchParams.get("test_id") || "";
|
||||||
const type = searchParams.get("type") || "";
|
const type = searchParams.get("type") || "";
|
||||||
|
|
||||||
// const { isOpen, close, open } = useModal();
|
|
||||||
const { timeRemaining, setInitialTime, stopTimer } = useTimer();
|
|
||||||
const { test, answers, startExam, setAnswer, submitExam, cancelExam } =
|
const { test, answers, startExam, setAnswer, submitExam, cancelExam } =
|
||||||
useExam();
|
useExamStore();
|
||||||
|
const { resetTimer, stopTimer } = useTimerStore();
|
||||||
|
|
||||||
// Start exam + timer
|
// Start exam + timer automatically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type && test_id) {
|
if (type && test_id) {
|
||||||
startExam(type, test_id).then(() => {
|
startExam(type, test_id).then((fetchedTest) => {
|
||||||
if (test?.metadata.time_limit_minutes) {
|
if (fetchedTest?.metadata.time_limit_minutes) {
|
||||||
setInitialTime(test.metadata.time_limit_minutes * 60); // convert to seconds
|
resetTimer(fetchedTest.metadata.time_limit_minutes * 60, () => {
|
||||||
|
// Timer ended → auto-submit exam
|
||||||
|
submitExam(type);
|
||||||
|
router.push(`/categories/${type}s`);
|
||||||
|
alert("Time's up! Your exam has been submitted.");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [type, test_id, startExam, setInitialTime]);
|
}, [type, test_id, startExam, resetTimer, submitExam, router]);
|
||||||
|
|
||||||
const showExitDialog = useCallback(() => {
|
const showExitDialog = useCallback(() => {
|
||||||
if (window.confirm("Are you sure you want to quit the exam?")) {
|
if (window.confirm("Are you sure you want to quit the exam?")) {
|
||||||
@ -38,7 +40,7 @@ export default function ExamPage() {
|
|||||||
cancelExam();
|
cancelExam();
|
||||||
router.push(`/categories/${type}s`);
|
router.push(`/categories/${type}s`);
|
||||||
}
|
}
|
||||||
}, [stopTimer, cancelExam, router]);
|
}, [stopTimer, cancelExam, router, type]);
|
||||||
|
|
||||||
if (!test) {
|
if (!test) {
|
||||||
return (
|
return (
|
||||||
@ -51,75 +53,16 @@ export default function ExamPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// answered set for modal overview
|
const handleSubmitExam = (type: string) => {
|
||||||
// const answeredSet = useMemo(
|
submitExam(type);
|
||||||
// () =>
|
router.push(`/categories/${type}s`);
|
||||||
// new Set(
|
};
|
||||||
// answers
|
|
||||||
// .map((a, idx) =>
|
|
||||||
// a !== null && a !== undefined ? idx.toString() : null
|
|
||||||
// )
|
|
||||||
// .filter(Boolean) as string[]
|
|
||||||
// ),
|
|
||||||
// [answers]
|
|
||||||
// );
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Header with live timer */}
|
{/* Header with live timer */}
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
{/* Modal: Question overview */}
|
|
||||||
{/* <Modal open={isOpen} onClose={close} title="Exam Overview">
|
|
||||||
<div>
|
|
||||||
<div className="flex gap-6 mb-4">
|
|
||||||
<p className="font-medium">Questions: {test.questions.length}</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
Answered:{" "}
|
|
||||||
<span className="text-blue-900 font-bold">
|
|
||||||
{answeredSet.size}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<p className="font-medium">
|
|
||||||
Skipped:{" "}
|
|
||||||
<span className="text-yellow-600 font-bold">
|
|
||||||
{test.questions.length - answeredSet.size}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-[0.5px] border-[0.5px] border-black/10 w-full my-3"></div>
|
|
||||||
|
|
||||||
<section className="flex flex-wrap gap-4">
|
|
||||||
{test.questions.map((q, idx) => {
|
|
||||||
const answered = answeredSet.has(String(idx));
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={q.question_id}
|
|
||||||
className={`h-12 w-12 rounded-full flex items-center justify-center cursor-pointer
|
|
||||||
${
|
|
||||||
answered
|
|
||||||
? "bg-blue-900 text-white"
|
|
||||||
: "bg-gray-200 text-gray-900"
|
|
||||||
}
|
|
||||||
hover:opacity-80 transition`}
|
|
||||||
onClick={() => {
|
|
||||||
// optional: scroll to question
|
|
||||||
const el = document.getElementById(`question-${idx}`);
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: "smooth" });
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{idx + 1}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</Modal> */}
|
|
||||||
|
|
||||||
{/* Questions */}
|
{/* Questions */}
|
||||||
<BackgroundWrapper>
|
<BackgroundWrapper>
|
||||||
<div className="container mx-auto px-6 py-8 mb-20">
|
<div className="container mx-auto px-6 py-8 mb-20">
|
||||||
@ -137,7 +80,7 @@ export default function ExamPage() {
|
|||||||
{/* Bottom submit bar */}
|
{/* Bottom submit bar */}
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex">
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex">
|
||||||
<button
|
<button
|
||||||
onClick={submitExam}
|
onClick={() => handleSubmitExam(type)}
|
||||||
className="flex-1 bg-blue-900 text-white p-6 font-bold text-lg hover:bg-blue-800 transition-colors"
|
className="flex-1 bg-blue-900 text-white p-6 font-bold text-lg hover:bg-blue-800 transition-colors"
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
|
|||||||
@ -16,11 +16,12 @@ import { API_URL, getToken } from "@/lib/auth";
|
|||||||
import { useExam } from "@/context/ExamContext";
|
import { useExam } from "@/context/ExamContext";
|
||||||
import { Test } from "@/types/exam";
|
import { Test } from "@/types/exam";
|
||||||
import { Metadata } from "@/types/exam";
|
import { Metadata } from "@/types/exam";
|
||||||
|
import { useExamStore } from "@/stores/examStore";
|
||||||
|
|
||||||
function PretestPageContent() {
|
function PretestPageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { startExam } = useExam();
|
const { startExam } = useExamStore();
|
||||||
const [examData, setExamData] = useState<Test>();
|
const [examData, setExamData] = useState<Test>();
|
||||||
|
|
||||||
// Get params from URL search params
|
// Get params from URL search params
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Montserrat } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
import AuthInitializer from "@/components/AuthInitializer";
|
||||||
|
|
||||||
const montserrat = Montserrat({
|
const montserrat = Montserrat({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@ -24,7 +25,9 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className={montserrat.variable}>
|
<html lang="en" className={montserrat.variable}>
|
||||||
<body className="font-sans">
|
<body className="font-sans">
|
||||||
<Providers>{children}</Providers>
|
<AuthInitializer>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</AuthInitializer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,13 +6,5 @@ import { AuthProvider } from "@/context/AuthContext";
|
|||||||
import { ModalProvider } from "@/context/ModalContext";
|
import { ModalProvider } from "@/context/ModalContext";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <ModalProvider>{children}</ModalProvider>;
|
||||||
<TimerProvider>
|
|
||||||
<AuthProvider>
|
|
||||||
<ExamProvider>
|
|
||||||
<ModalProvider>{children}</ModalProvider>
|
|
||||||
</ExamProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</TimerProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
40
components/AuthInitializer.tsx
Normal file
40
components/AuthInitializer.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export default function AuthInitializer({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { initializeAuth, isLoading, token } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const run = async () => {
|
||||||
|
await initializeAuth();
|
||||||
|
|
||||||
|
// Routing logic based on auth state
|
||||||
|
const publicPages = ["/", "/login", "/register"];
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
if (publicPages.includes(pathname)) {
|
||||||
|
router.replace("/home");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!publicPages.includes(pathname)) {
|
||||||
|
router.replace("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
}, [pathname, token, initializeAuth, router]);
|
||||||
|
|
||||||
|
if (isLoading) return <p>Loading...</p>;
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import React, { JSX } from "react";
|
|||||||
|
|
||||||
interface DestructibleAlertProps {
|
interface DestructibleAlertProps {
|
||||||
text: string;
|
text: string;
|
||||||
icon: JSX.Element;
|
icon?: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DestructibleAlert: React.FC<DestructibleAlertProps> = ({
|
const DestructibleAlert: React.FC<DestructibleAlertProps> = ({
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import { useExam } from "@/context/ExamContext";
|
|||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { useModal } from "@/context/ModalContext";
|
import { useModal } from "@/context/ModalContext";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { useTimerStore } from "@/stores/timerStore";
|
||||||
|
import { useExamStore } from "@/stores/examStore";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
displayUser?: boolean;
|
displayUser?: boolean;
|
||||||
@ -23,9 +26,9 @@ const Header = ({
|
|||||||
}: HeaderProps) => {
|
}: HeaderProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { open } = useModal();
|
const { open } = useModal();
|
||||||
const { cancelExam } = useExam();
|
const { cancelExam } = useExamStore();
|
||||||
const { stopTimer, timeRemaining } = useTimer();
|
const { stopTimer, timeRemaining } = useTimerStore();
|
||||||
const { user } = useAuth();
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
const showExitDialog = () => {
|
const showExitDialog = () => {
|
||||||
const confirmed = window.confirm("Are you sure you want to quit the exam?");
|
const confirmed = window.confirm("Are you sure you want to quit the exam?");
|
||||||
|
|||||||
32
package-lock.json
generated
32
package-lock.json
generated
@ -20,7 +20,8 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^7.4.2",
|
"@capacitor/cli": "^7.4.2",
|
||||||
@ -7789,6 +7790,35 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,8 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/cli": "^7.4.2",
|
"@capacitor/cli": "^7.4.2",
|
||||||
|
|||||||
90
stores/authStore.ts
Normal file
90
stores/authStore.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { UserData } from "@/types/auth";
|
||||||
|
import { API_URL } from "@/lib/auth";
|
||||||
|
|
||||||
|
// Cookie utilities
|
||||||
|
const getCookie = (name: string): string | null => {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return parts.pop()?.split(";").shift() || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCookie = (
|
||||||
|
name: string,
|
||||||
|
value: string | null,
|
||||||
|
days: number = 7
|
||||||
|
): void => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict; Secure`;
|
||||||
|
} else {
|
||||||
|
const expires = new Date();
|
||||||
|
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
document.cookie = `${name}=${value}; expires=${expires.toUTCString()}; path=/; SameSite=Strict; Secure`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
user: UserData | null;
|
||||||
|
|
||||||
|
setToken: (token: string | null) => void;
|
||||||
|
fetchUser: () => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
initializeAuth: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
|
token: null,
|
||||||
|
isLoading: true,
|
||||||
|
user: null,
|
||||||
|
|
||||||
|
setToken: (newToken) => {
|
||||||
|
set({ token: newToken });
|
||||||
|
setCookie("authToken", newToken);
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchUser: async () => {
|
||||||
|
const token = get().token;
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/me/profile/`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch user info");
|
||||||
|
|
||||||
|
const data: UserData = await res.json();
|
||||||
|
set({ user: data });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching user:", err);
|
||||||
|
get().logout();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
set({ token: null, user: null });
|
||||||
|
setCookie("authToken", null);
|
||||||
|
},
|
||||||
|
|
||||||
|
initializeAuth: async () => {
|
||||||
|
const storedToken = getCookie("authToken");
|
||||||
|
if (storedToken) {
|
||||||
|
set({ token: storedToken });
|
||||||
|
await get().fetchUser();
|
||||||
|
}
|
||||||
|
set({ isLoading: false });
|
||||||
|
},
|
||||||
|
}));
|
||||||
90
stores/examStore.ts
Normal file
90
stores/examStore.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { Test, Answer } from "@/types/exam";
|
||||||
|
import { API_URL, getToken } from "@/lib/auth";
|
||||||
|
|
||||||
|
interface ExamState {
|
||||||
|
test: Test | null;
|
||||||
|
answers: Answer[];
|
||||||
|
|
||||||
|
startExam: (testType: string, testId: string) => Promise<void>;
|
||||||
|
setAnswer: (questionIndex: number, answer: Answer) => void;
|
||||||
|
submitExam: (testType: string) => Promise<void>;
|
||||||
|
cancelExam: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useExamStore = create<ExamState>((set, get) => ({
|
||||||
|
test: null,
|
||||||
|
answers: [],
|
||||||
|
|
||||||
|
// start exam
|
||||||
|
startExam: async (testType: string, testId: string) => {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
const res = await fetch(`${API_URL}/tests/${testType}/${testId}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch test: ${res.status}`);
|
||||||
|
const data: Test = await res.json();
|
||||||
|
|
||||||
|
set({
|
||||||
|
test: data,
|
||||||
|
answers: Array(data.questions.length).fill(null),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("startExam error:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// set an answer
|
||||||
|
setAnswer: (questionIndex: number, answer: Answer) => {
|
||||||
|
set((state) => {
|
||||||
|
const updated = [...state.answers];
|
||||||
|
updated[questionIndex] = answer;
|
||||||
|
return { answers: updated };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// submit exam
|
||||||
|
submitExam: async (testType: string) => {
|
||||||
|
const { test, answers } = get();
|
||||||
|
if (!test) return;
|
||||||
|
|
||||||
|
const token = await getToken();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { test_id, attempt_id } = test.metadata;
|
||||||
|
console.log(answers);
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/tests/${testType}/${test_id}/${attempt_id}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ answers }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(res);
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to submit exam");
|
||||||
|
|
||||||
|
// reset store
|
||||||
|
set({ test: null, answers: [] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to submit exam. Reason:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// cancel exam
|
||||||
|
cancelExam: () => {
|
||||||
|
set({ test: null, answers: [] });
|
||||||
|
},
|
||||||
|
}));
|
||||||
48
stores/timerStore.ts
Normal file
48
stores/timerStore.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface TimerState {
|
||||||
|
timeRemaining: number;
|
||||||
|
timerRef: NodeJS.Timeout | null;
|
||||||
|
|
||||||
|
resetTimer: (duration: number, onComplete?: () => void) => void;
|
||||||
|
stopTimer: () => void;
|
||||||
|
setInitialTime: (duration: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTimerStore = create<TimerState>((set, get) => ({
|
||||||
|
timeRemaining: 0,
|
||||||
|
timerRef: null,
|
||||||
|
|
||||||
|
resetTimer: (duration, onComplete) => {
|
||||||
|
const { timerRef } = get();
|
||||||
|
|
||||||
|
if (timerRef) clearInterval(timerRef);
|
||||||
|
|
||||||
|
const newRef = setInterval(() => {
|
||||||
|
set((state) => {
|
||||||
|
if (state.timeRemaining <= 1) {
|
||||||
|
clearInterval(newRef);
|
||||||
|
if (onComplete) onComplete(); // ✅ call callback when timer ends
|
||||||
|
return { timeRemaining: 0, timerRef: null };
|
||||||
|
}
|
||||||
|
return { timeRemaining: state.timeRemaining - 1 };
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
set({ timeRemaining: duration, timerRef: newRef });
|
||||||
|
},
|
||||||
|
|
||||||
|
stopTimer: () => {
|
||||||
|
const { timerRef } = get();
|
||||||
|
if (timerRef) clearInterval(timerRef);
|
||||||
|
set({ timeRemaining: 0, timerRef: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
setInitialTime: (duration) => {
|
||||||
|
const { timerRef } = get();
|
||||||
|
if (timerRef) clearInterval(timerRef);
|
||||||
|
set({ timeRemaining: duration, timerRef: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user