29 Commits

Author SHA1 Message Date
11108ad8cf fix(ui): fix ui icons and readability 2025-11-12 15:38:07 +06:00
d1947e8e6e fix(auth): fix fetch user info at login 2025-10-07 13:50:29 +06:00
e1a33d1398 fix(nav): improve redirection logic 2025-10-06 19:16:03 +06:00
981fe6973f fix(ui): improve ui/ux and visibility 2025-10-06 18:39:58 +06:00
351c8eab48 feat(ui): improve error handling on categories page 2025-10-06 18:14:00 +06:00
108d34988d fix(nav): fix exam flow navigation
chore(zustand): refactor auth code for zustand store
2025-09-09 20:45:30 +06:00
c3ead879ad feat(auth): add verification feature in settings 2025-09-09 00:54:06 +06:00
4042e28bf7 feat(ui): add verified badge to header 2025-09-08 14:30:01 +06:00
53a2228dc9 fix(nav): improve navigation for authorization routes 2025-09-08 14:15:27 +06:00
99d6c15e38 chore(capacitor): refactor codebase for capacitor entry 2025-09-08 13:42:15 +06:00
3b2488054c feat(nav): add flow-guarding for exam result screens 2025-09-01 17:53:44 +06:00
5fd76bc0ec fix(ui): fix timer reappearing after exam submission 2025-09-01 17:13:35 +06:00
5507602031 fix(ui): refactor results page for exam results logic 2025-08-31 23:27:32 +06:00
7df2708db7 feat(zustand): add zustand stores for exam, timer and auth 2025-08-31 18:28:01 +06:00
65e3338859 fix(api): exam logic yet to be fixed 2025-08-31 17:45:08 +06:00
b112a8fdac fix(api): fix api logic for exam screen
needs more work for the timercontext
2025-08-31 02:20:55 +06:00
08a560abe5 fix(api): fix exam screen api logic 2025-08-27 23:45:37 +06:00
84bc192e02 feat(ui): add icons to destructible alert 2025-08-23 13:51:11 +06:00
46608356ee fix(ui): minor ui tweak in pretest screen 2025-08-23 13:36:26 +06:00
6f4f2a8668 feat(ui): add topic, subject screen
fix(api): fix api endpoint logic for pretest screen
2025-08-21 14:19:55 +06:00
be5c723bff feat(ui): add topic test screen 2025-08-20 20:11:15 +06:00
399c0b0060 chore(ui): refactor subjects ui 2025-08-19 00:28:49 +06:00
d74b81e962 fix(api): fix api endpoint logic #6 2025-08-18 17:48:32 +06:00
58d4d14a51 fix(api): fix api endpoint logic #5
chore(env): obscure api url in env

feat(ui): render subjects according to user preparation unit
2025-08-18 15:06:50 +06:00
e3673951c6 fix(api): fix api endpoint logic #3 2025-08-17 19:59:14 +06:00
4f23f357e6 fix(api): fix api endpoint logic #3 2025-08-17 14:08:17 +06:00
ad46bf954e fix(api): fix api endpoint logic #2 2025-08-16 17:05:40 +06:00
713696760e fix(api): fix api endpoint logic #1 2025-08-10 19:25:25 +06:00
0bca09f8ef fix(ts): refactor codebase for capacitor setup 2025-07-28 20:22:04 +06:00
57 changed files with 6270 additions and 2805 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
NEXT_PUBLIC_EXAMJAM_API_URL=api_url_here

5
.gitignore vendored
View File

@ -16,6 +16,7 @@
# next.js # next.js
/.next/ /.next/
/out/ /out/
android
# production # production
/build /build
@ -31,7 +32,7 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env
# vercel # vercel
.vercel .vercel
@ -39,3 +40,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.vercel

View File

@ -6,31 +6,42 @@ import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import BackgroundWrapper from "@/components/BackgroundWrapper"; import BackgroundWrapper from "@/components/BackgroundWrapper";
import FormField from "@/components/FormField"; import FormField from "@/components/FormField";
import { login } from "@/lib/auth";
import DestructibleAlert from "@/components/DestructibleAlert"; import DestructibleAlert from "@/components/DestructibleAlert";
import { useAuth } from "@/context/AuthContext"; import { LoginForm } from "@/types/auth";
import { CircleAlert } from "lucide-react";
import { useAuthStore } from "@/stores/authStore";
const page = () => { const LoginPage = () => {
const router = useRouter(); const router = useRouter();
const { setToken } = useAuth(); const { login } = useAuthStore();
const [form, setForm] = useState({
email: "", const [form, setForm] = useState<LoginForm>({
identifier: "",
password: "", password: "",
}); });
const [error, setError] = useState(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// For Rafeed
// Function to login a user. I've kept it in a barebones form right now, but you can just call the login function from /lib/auth.ts and pass on the form.
const loginUser = async () => { const loginUser = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
await login(form, setToken); // Call the login function await login(form);
router.push("/home"); // Redirect on successful login router.replace("/home");
} catch (error) { } catch (err: unknown) {
console.log(error); console.error(err);
setError(error.message); // Handle error messages
if (
typeof err === "object" &&
err !== null &&
"message" in err &&
typeof err.message === "string"
) {
setError(err.message);
} else {
setError("An unexpected error occurred.");
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -60,20 +71,28 @@ const page = () => {
<div className="flex flex-col justify-between gap-10"> <div className="flex flex-col justify-between gap-10">
<div className="flex flex-col w-full gap-5"> <div className="flex flex-col w-full gap-5">
<FormField <FormField
title="Email Address" title="Email \ Username"
value={form.email} value={form.identifier}
placeholder="Enter your email address..." placeholder="Enter your email address..."
handleChangeText={(e) => setForm({ ...form, email: e })} handleChangeText={(value) =>
setForm({ ...form, identifier: value })
}
/> />
<FormField <FormField
title="Password" title="Password"
value={form.password} value={form.password}
placeholder="Enter a password" placeholder="Enter a password"
handleChangeText={(e) => setForm({ ...form, password: e })} handleChangeText={(value) =>
setForm({ ...form, password: value })
}
/> />
</div> </div>
{error && <DestructibleAlert text={error} />}
{error && <DestructibleAlert text={error} extraStyles="" />} <h1 className="flex justify-center items-center gap-2 bg-green-200 p-4 rounded-full">
<CircleAlert size={20} />
Your login details will be remembered.
</h1>
<button <button
onClick={loginUser} onClick={loginUser}
@ -94,7 +113,7 @@ const page = () => {
className="text-center mb-[70px]" className="text-center mb-[70px]"
style={{ fontFamily: "Montserrat, sans-serif" }} style={{ fontFamily: "Montserrat, sans-serif" }}
> >
Don't have an account?{" "} Don&apos;t have an account?{" "}
<Link href="/register" className="text-[#276ac0] hover:underline"> <Link href="/register" className="text-[#276ac0] hover:underline">
Register here. Register here.
</Link> </Link>
@ -106,4 +125,4 @@ const page = () => {
); );
}; };
export default page; export default LoginPage;

View File

@ -4,42 +4,69 @@ import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { register } from "@/lib/auth";
import { useAuth } from "@/context/AuthContext";
import BackgroundWrapper from "@/components/BackgroundWrapper"; 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 { useAuthStore } from "@/stores/authStore";
interface ValidationErrorItem {
type: string;
loc: string[];
msg: string;
input?: unknown;
ctx?: Record<string, unknown>;
}
interface CustomError extends Error {
response?: {
detail?: string | ValidationErrorItem;
};
}
export default function RegisterPage() { export default function RegisterPage() {
const { setToken } = useAuth(); const { register } = useAuthStore();
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState({ const [form, setForm] = useState<RegisterForm>({
name: "", full_name: "",
institution: "", username: "",
sscRoll: "",
hscRoll: "",
email: "", email: "",
phone: "",
password: "", password: "",
phone_number: "",
ssc_roll: 0,
ssc_board: "",
hsc_roll: 0,
hsc_board: "",
college: "",
preparation_unit: "",
}); });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleError = (error: any) => { function formatError(error: unknown): string {
if (error?.detail) { if (error && typeof error === "object" && "response" in (error as any)) {
const match = error.detail.match(/Key \((.*?)\)=\((.*?)\)/); const customError = error as CustomError;
if (match) { const detail = customError.response?.detail;
const field = match[1];
const value = match[2]; if (typeof detail === "string") {
return `The ${field} already exists. Please use a different value.`; return detail; // plain backend error string
}
if (Array.isArray(detail)) {
// Pick the first validation error, or join them if multiple
return detail.map((d) => d.msg).join("; ");
} }
} }
return "An unexpected error occurred. Please try again.";
}; if (error instanceof Error) {
return error.message;
}
return "An unexpected error occurred.";
}
const validateForm = () => { const validateForm = () => {
const { sscRoll, hscRoll, password } = form; const { ssc_roll, hsc_roll, password } = form;
if (sscRoll === hscRoll) { if (ssc_roll === hsc_roll) {
return "SSC Roll and HSC Roll must be unique."; return "SSC Roll and HSC Roll must be unique.";
} }
const passwordRegex = const passwordRegex =
@ -56,28 +83,23 @@ export default function RegisterPage() {
setError(validationError); setError(validationError);
return; return;
} }
try { try {
await register(form, setToken); await register(form);
router.push("/home"); router.replace("/login");
} catch (error: any) { } catch (err: unknown) {
console.error("Error:", error.response || error.message); setError(formatError(err));
if (error.response?.detail) { console.error("User creation error: ", err);
const decodedError = handleError({ detail: error.response.detail });
setError(decodedError);
} else {
setError(error.message || "An unexpected error occurred.");
}
} }
}; };
return ( return (
<BackgroundWrapper> <BackgroundWrapper>
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-10"> <div className="min-h-screen flex flex-col items-center justify-center px-4 py-10">
<div className="w-full max-w-md space-y-6"> <div className="w-full space-y-6">
<div className="w-full aspect-[368/89] mx-auto"> <div className="w-full aspect-[368/89] mx-auto">
<Image <Image
src="/images/logo/logo.png" src="/images/logo/logo.png"
alt="Logo" alt="logo"
width={368} width={368}
height={89} height={89}
className="w-full h-auto" className="w-full h-auto"
@ -86,48 +108,34 @@ export default function RegisterPage() {
<div className="space-y-10"> <div className="space-y-10">
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-semibold">Personal Info</h1>
<FormField <FormField
title="Full name" title="Full name"
value={form.name} value={form.full_name}
handleChangeText={(value) => setForm({ ...form, name: value })}
/>
<FormField
title="Institution"
value={form.institution}
handleChangeText={(value) => handleChangeText={(value) =>
setForm({ ...form, institution: value }) setForm({ ...form, full_name: value })
} }
/> />
<FormField <FormField
title="SSC Roll No." title="User name"
value={form.sscRoll} value={form.username}
handleChangeText={(value) => handleChangeText={(value) =>
setForm({ ...form, sscRoll: value }) setForm({ ...form, username: value })
} }
/> />
<FormField <FormField
title="HSC Roll No." title="Phone Number"
value={form.hscRoll} value={form.phone_number}
handleChangeText={(value) => handleChangeText={(value) =>
setForm({ ...form, hscRoll: value }) setForm({ ...form, phone_number: value })
} }
/> />
<FormField <FormField
title="Email Address" title="Email Address"
value={form.email} value={form.email}
handleChangeText={(value) => setForm({ ...form, email: value })} handleChangeText={(value) => setForm({ ...form, email: value })}
/> />
<FormField
title="Phone Number"
value={form.phone}
handleChangeText={(value) => setForm({ ...form, phone: value })}
/>
<FormField <FormField
title="Password" title="Password"
value={form.password} value={form.password}
@ -136,6 +144,58 @@ export default function RegisterPage() {
} }
placeholder={undefined} placeholder={undefined}
/> />
<h1 className="text-2xl font-semibold">Educational Background</h1>
<FormField
title="College"
value={form.college}
handleChangeText={(value) =>
setForm({ ...form, college: value })
}
/>
<FormField
title="Preparation Unit"
value={form.preparation_unit}
handleChangeText={(value) =>
setForm({ ...form, preparation_unit: value })
}
/>
<div className="w-full flex gap-4">
<FormField
title="SSC Board"
value={form.ssc_board}
handleChangeText={(value) =>
setForm({ ...form, ssc_board: value })
}
/>
<FormField
title="SSC Roll No."
value={form.ssc_roll}
handleChangeText={(value: string) =>
setForm({ ...form, ssc_roll: Number(value) })
}
className="max-w-26"
/>
</div>
<div className="w-full flex gap-4">
<FormField
title="HSC Board"
value={form.hsc_board}
handleChangeText={(value) =>
setForm({ ...form, hsc_board: value })
}
/>
<FormField
title="HSC Roll No."
value={form.hsc_roll}
handleChangeText={(value: string) =>
setForm({ ...form, hsc_roll: Number(value) })
}
className="max-w-26"
/>
</div>
</div> </div>
{error && <DestructibleAlert text={error} />} {error && <DestructibleAlert text={error} />}
@ -148,7 +208,7 @@ export default function RegisterPage() {
</button> </button>
<p className="text-center text-sm"> <p className="text-center text-sm">
Already have an account?{" "} Already have an account?
<Link href="/login" className="text-blue-600"> <Link href="/login" className="text-blue-600">
Login here Login here
</Link> </Link>

View File

@ -1,63 +1,64 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React from "react";
import BackgroundWrapper from "@/components/BackgroundWrapper"; import BackgroundWrapper from "@/components/BackgroundWrapper";
import { Bookmark, BookmarkCheck, ListFilter, MoveLeft } from "lucide-react"; import { ListFilter, MoveLeft } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import DestructibleAlert from "@/components/DestructibleAlert";
interface Question { // interface Question {
id: number; // id: number;
question: string; // question: string;
options: Record<string, string>; // options: Record<string, string>;
} // }
interface QuestionItemProps { // interface QuestionItemProps {
question: Question; // question: Question;
} // }
const QuestionItem = ({ question }: QuestionItemProps) => { // const QuestionItem = ({ question }: QuestionItemProps) => {
const [bookmark, setBookmark] = useState(true); // const [bookmark, setBookmark] = useState(true);
return ( // return (
<div className="border border-[#8abdff]/50 rounded-2xl p-4 flex flex-col gap-7"> // <div className="border border-[#8abdff]/50 rounded-2xl p-4 flex flex-col gap-7">
<h3 className="text-xl font-medium"> // <h3 className="text-xl font-medium">
{question.id + 1}. {question.question} // {question.id + 1}. {question.question}
</h3> // </h3>
<div className="flex justify-between items-center"> // <div className="flex justify-between items-center">
<div></div> // <div></div>
<button onClick={() => setBookmark(!bookmark)}> // <button onClick={() => setBookmark(!bookmark)}>
{bookmark ? ( // {bookmark ? (
<BookmarkCheck size={25} color="#113768" /> // <BookmarkCheck size={25} color="#113768" />
) : ( // ) : (
<Bookmark size={25} color="#113768" /> // <Bookmark size={25} color="#113768" />
)} // )}
</button> // </button>
</div> // </div>
<div className="flex flex-col gap-4 items-start"> // <div className="flex flex-col gap-4 items-start">
{Object.entries(question.options).map(([key, value]) => { // {Object.entries(question.options).map(([key, value]) => {
return ( // return (
<div key={key} className="flex items-center gap-3"> // <div key={key} className="flex items-center gap-3">
<span className="px-2 py-1 flex items-center rounded-full border font-medium text-sm"> // <span className="px-2 py-1 flex items-center rounded-full border font-medium text-sm">
{key.toUpperCase()} // {key.toUpperCase()}
</span> // </span>
<span className="option-description">{value}</span> // <span className="option-description">{value}</span>
</div> // </div>
); // );
})} // })}
</div> // </div>
</div> // </div>
); // );
}; // };
const BookmarkPage = () => { const BookmarkPage = () => {
const router = useRouter(); const router = useRouter();
const [questions, setQuestions] = useState(); // const [questions, setQuestions] = useState<Question[]>([]);
useEffect(() => { // useEffect(() => {
fetch("/data/bookmark.json") // fetch("/data/bookmark.json")
.then((res) => res.json()) // .then((res) => res.json())
.then((data) => setQuestions(data)) // .then((data) => setQuestions(data))
.catch((err) => console.error("Error loading questions: ", err)); // .catch((err) => console.error("Error loading questions: ", err));
}, []); // }, []);
return ( return (
<BackgroundWrapper> <BackgroundWrapper>
@ -74,9 +75,13 @@ const BookmarkPage = () => {
<ListFilter size={24} color="#113768" /> <ListFilter size={24} color="#113768" />
</button> </button>
</div> </div>
{questions?.map((question) => ( {/* {questions.map((question: Question) => (
<QuestionItem key={question.id} question={question} /> <QuestionItem key={question.id} question={question} />
))} ))} */}
<DestructibleAlert
text="Page under construction"
variant="warning"
/>
</div> </div>
</div> </div>
</section> </section>

View File

@ -0,0 +1,150 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Header from "@/components/Header";
import DestructibleAlert from "@/components/DestructibleAlert";
import BackgroundWrapper from "@/components/BackgroundWrapper";
import { API_URL, getToken } from "@/lib/auth";
import { Loader, RefreshCw } from "lucide-react";
import { useAuthStore } from "@/stores/authStore";
type Mock = {
test_id: string;
name: string;
type: string;
num_questions: number;
time_limit_minutes: number;
deduction: number;
total_possible_score: number;
unit: string;
};
export default function MockScreen() {
const router = useRouter();
const { user } = useAuthStore();
const [mocks, setMocks] = useState<Mock[]>([]);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState<boolean>(false);
async function fetchMocks() {
setRefreshing(true);
try {
const token = await getToken();
const response = await fetch(`${API_URL}/tests/mock/`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Failed to fetch topics");
}
const fetchedMocks: Mock[] = await response.json();
setMocks(fetchedMocks);
} catch (error) {
setErrorMsg(
"Error fetching mocks: " +
(error instanceof Error ? error.message : "Unknown error")
);
} finally {
setRefreshing(false);
}
}
useEffect(() => {
const fetchData = async () => {
if (await getToken()) {
fetchMocks();
}
};
fetchData();
}, []);
const onRefresh = async () => {
fetchMocks();
};
if (errorMsg) {
return (
<BackgroundWrapper>
<Header displayTabTitle="Mocks" />
<div className="overflow-y-auto">
<div className="mt-5 px-5">
<h1 className="text-2xl font-semibold my-5">
{user?.preparation_unit}
</h1>
<DestructibleAlert text={errorMsg} variant="error" />
</div>
<div className="flex justify-center mt-4">
<button
onClick={onRefresh}
disabled={refreshing}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{refreshing ? <Loader /> : <RefreshCw />}
</button>
</div>
</div>
</BackgroundWrapper>
);
}
return (
<BackgroundWrapper>
<div>
<Header displayTabTitle="Mocks" />
<div className="mx-10 pb-20 overflow-y-auto">
<h1 className="text-2xl font-semibold my-5">
{user?.preparation_unit}
</h1>
<div className="border border-[#c0dafc]/50 flex flex-col gap-4 w-full rounded-[25px] p-3">
{mocks.length > 0 ? (
mocks.map((mocks) => (
<div key={mocks.test_id}>
<button
onClick={() =>
router.push(
`/exam/pretest?type=mock&test_id=${mocks.test_id}`
)
}
className="w-full border-1 border-[#B0C2DA] py-3 rounded-[10px] px-6 space-y-2 text-left hover:bg-gray-50 transition-colors"
>
<h3 className="text-lg font-medium">{mocks.name}</h3>
<div className="flex space-x-2">
<p className="text-sm font-normal bg-slate-500 w-fit px-3 py-1 rounded-full text-white">
{mocks.unit}
</p>
</div>
</button>
</div>
))
) : refreshing ? (
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p className="text-xl font-medium text-center">Loading...</p>
</div>
) : (
<DestructibleAlert
text="There aren't any tests available for you. Check back later?"
variant="warning"
/>
)}
</div>
<div className="flex justify-center mt-4">
<button
onClick={onRefresh}
disabled={refreshing}
className="p-2 bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 rounded-full"
>
{refreshing ? <Loader /> : <RefreshCw />}
</button>
</div>
</div>
</div>
{/* <CustomBackHandler fallbackRoute="unit" useCustomHandler={false} /> */}
</BackgroundWrapper>
);
}

View File

@ -1,7 +1,75 @@
import React from "react"; "use client";
const page = () => { import BackgroundWrapper from "@/components/BackgroundWrapper";
return <div>page</div>; import Header from "@/components/Header";
import React from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
const CategoriesPage = () => {
const router = useRouter();
return (
<BackgroundWrapper>
<main>
<Header displayTabTitle="Categories" />
<div className="grid grid-cols-2 gap-4 pt-6 mx-4">
<button
onClick={() => router.push("/categories/topics")}
className="flex flex-col justify-center items-center border-[1px] border-blue-200 aspect-square rounded-3xl gap-2 bg-white"
>
<Image
src="/images/icons/topic-test.png"
alt="Topic Test"
width={85}
height={85}
/>
<span className="font-medium text-[#113768]">Topic Test</span>
</button>
<button
onClick={() => router.push("/categories/mocks")}
className="flex flex-col justify-center items-center border-[1px] border-blue-200 aspect-square rounded-3xl gap-2 bg-white"
>
<Image
src="/images/icons/mock-test.png"
alt="Mock Test"
width={85}
height={85}
/>
<span className="font-medium text-[#113768]">Mock Test</span>
</button>
<button
disabled
className="flex flex-col justify-center items-center border-[1px] border-blue-200 aspect-square rounded-3xl gap-2 bg-white"
>
<Image
src="/images/icons/past-paper.png"
alt="Past Papers"
width={68}
height={68}
className="opacity-50"
/>
<span className="font-medium text-[#113768]/50">Past Papers</span>
</button>
<button
onClick={() => router.push("/categories/subjects")}
className="flex flex-col justify-center items-center border-[1px] border-blue-200 aspect-square rounded-3xl gap-2 bg-white"
>
<Image
src="/images/icons/subject-test.png"
alt="Subject Test"
width={80}
height={80}
/>
<span className="font-medium text-[#113768]">Subject Test</span>
</button>
</div>
</main>
</BackgroundWrapper>
);
}; };
export default page; export default CategoriesPage;

View File

@ -0,0 +1,144 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Header from "@/components/Header";
import DestructibleAlert from "@/components/DestructibleAlert";
import BackgroundWrapper from "@/components/BackgroundWrapper";
import { API_URL, getToken } from "@/lib/auth";
import { Loader, RefreshCw } from "lucide-react";
import { useAuthStore } from "@/stores/authStore";
import { FaStar } from "react-icons/fa";
type Subject = {
subject_id: string;
name: string;
unit: string;
};
export default function PaperScreen() {
const router = useRouter();
const { user } = useAuthStore();
const [subjects, setSubjects] = useState<Subject[]>([]);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState<boolean>(false);
async function fetchSubjects() {
setRefreshing(true);
try {
const token = await getToken();
const response = await fetch(`${API_URL}/subjects/`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Failed to fetch subjects");
}
const fetchedSubjects: Subject[] = await response.json();
setSubjects(fetchedSubjects);
} catch (error) {
setErrorMsg(
"Error fetching subjects: " +
(error instanceof Error ? error.message : "Unknown error")
);
} finally {
setRefreshing(false);
}
}
useEffect(() => {
const fetchData = async () => {
if (await getToken()) {
fetchSubjects();
}
};
fetchData();
}, []);
const onRefresh = async () => {
fetchSubjects();
};
if (errorMsg) {
return (
<BackgroundWrapper>
<Header displayTabTitle="Subjects" />
<div className="overflow-y-auto">
<div className="mt-5 px-5">
<h1 className="text-2xl font-semibold my-5">
{user?.preparation_unit}
</h1>
<DestructibleAlert text={errorMsg} variant="error" />
</div>
<div className="flex justify-center mt-4">
<button
onClick={onRefresh}
disabled={refreshing}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{refreshing ? <Loader /> : <RefreshCw />}
</button>
</div>
</div>
</BackgroundWrapper>
);
}
return (
<BackgroundWrapper>
<div>
<Header displayTabTitle="Subjects" />
<div className="mx-10 pt-5 overflow-y-auto">
<h1 className="text-2xl font-semibold mb-5">
{user?.preparation_unit}
</h1>
<div className="border border-[#c0dafc]/50 flex flex-col gap-4 w-full rounded-[20px] p-3">
{subjects.length > 0 ? (
subjects
.filter((subject) => subject.unit === user?.preparation_unit)
.map((subject) => (
<div key={subject.subject_id}>
<button
onClick={() =>
router.push(
`/exam/pretest?type=subject&test_id=${subject.subject_id}`
)
}
className="w-full border-1 border-[#B0C2DA] py-4 rounded-[10px] px-6 space-y-2 text-left hover:bg-gray-50 transition-colors"
>
<h3 className="text-xl font-medium">{subject.name}</h3>
<p className="text-xl font-medium text-[#113768] flex items-center gap-1">
<FaStar size={15} />
<FaStar size={15} />
<FaStar size={15} />
<FaStar size={15} />
</p>
</button>
</div>
))
) : (
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p className="text-xl font-medium text-center">Loading...</p>
</div>
)}
</div>
<div className="flex justify-center mt-4">
<button
onClick={onRefresh}
disabled={refreshing}
className="p-2 bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 rounded-full"
>
{refreshing ? <Loader /> : <RefreshCw />}
</button>
</div>
</div>
</div>
{/* <CustomBackHandler fallbackRoute="unit" useCustomHandler={false} /> */}
</BackgroundWrapper>
);
}

View File

@ -0,0 +1,144 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Header from "@/components/Header";
import DestructibleAlert from "@/components/DestructibleAlert";
import BackgroundWrapper from "@/components/BackgroundWrapper";
import { API_URL, getToken } from "@/lib/auth";
import { Loader, RefreshCw } from "lucide-react";
import { useAuthStore } from "@/stores/authStore";
type Topic = {
topic_id: string;
name: string;
subject: {
subject_id: string;
name: string;
unit: string;
};
};
export default function TopicScreen() {
const router = useRouter();
const { user } = useAuthStore();
const [topics, setTopics] = useState<Topic[]>([]);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState<boolean>(false);
async function fetchTopics() {
setRefreshing(true);
try {
const token = await getToken();
const response = await fetch(`${API_URL}/topics/`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error("Failed to fetch topics");
}
const fetchedTopics: Topic[] = await response.json();
setTopics(fetchedTopics);
} catch (error) {
setErrorMsg(
"Error fetching subjects: " +
(error instanceof Error ? error.message : "Unknown error")
);
} finally {
setRefreshing(false);
}
}
useEffect(() => {
const fetchData = async () => {
if (await getToken()) {
fetchTopics();
}
};
fetchData();
}, []);
const onRefresh = async () => {
fetchTopics();
};
if (errorMsg) {
return (
<BackgroundWrapper>
<Header displayTabTitle="Subjects" />
<div className="overflow-y-auto">
<div className="mt-5 px-5">
<h1 className="text-2xl font-semibold my-5">
{user?.preparation_unit}
</h1>
<DestructibleAlert text={errorMsg} variant="error" />
</div>
<div className="flex justify-center mt-4">
<button
onClick={onRefresh}
disabled={refreshing}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{refreshing ? <Loader /> : <RefreshCw />}
</button>
</div>
</div>
</BackgroundWrapper>
);
}
return (
<BackgroundWrapper>
<div>
<Header displayTabTitle="Topics" />
<div className="mx-10 pb-20 overflow-y-auto">
<h1 className="text-2xl font-semibold my-5">
{user?.preparation_unit}
</h1>
<div className="border border-[#c0dafc]/50 flex flex-col gap-4 w-full rounded-[20px] p-3">
{topics.length > 0 ? (
topics.map((topic) => (
<div key={topic.topic_id}>
<button
onClick={() =>
router.push(
`/exam/pretest?type=topic&test_id=${topic.topic_id}`
)
}
className="w-full border-1 border-[#B0C2DA] py-3 rounded-[10px] px-6 space-y-2 text-left hover:bg-gray-50 transition-colors"
>
<h3 className="text-lg font-medium">{topic.name}</h3>
<div className="flex space-x-2">
<p className="text-sm font-normal bg-slate-500 w-fit px-3 py-1 rounded-full text-white">
{topic.subject.name}
</p>
</div>
</button>
</div>
))
) : (
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p className="text-xl font-medium text-center">Loading...</p>
</div>
)}
</div>
<div className="flex justify-center mt-4">
<button
onClick={onRefresh}
disabled={refreshing}
className="p-2 bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 rounded-full"
>
{refreshing ? <Loader /> : <RefreshCw />}
</button>
</div>
</div>
</div>
{/* <CustomBackHandler fallbackRoute="unit" useCustomHandler={false} /> */}
</BackgroundWrapper>
);
}

View File

@ -1,76 +1,48 @@
"use client"; "use client";
import React, { useState, useEffect, ReactNode } from "react"; import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import Header from "@/components/Header"; import Header from "@/components/Header";
import SlidingGallery from "@/components/SlidingGallery"; import SlidingGallery from "@/components/SlidingGallery";
import BackgroundWrapper from "@/components/BackgroundWrapper"; import BackgroundWrapper from "@/components/BackgroundWrapper";
import DestructibleAlert from "@/components/DestructibleAlert";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import styles from "@/css/Home.module.css"; import styles from "@/css/Home.module.css";
import { API_URL } from "@/lib/auth";
import { Avatar } from "@/components/ui/avatar";
import { getLinkedViews } from "@/lib/gallery-views"; import { getLinkedViews } from "@/lib/gallery-views";
import { getTopThree } from "@/lib/leaderboard"; import { GalleryViews } from "@/types/gallery";
import { useAuthStore } from "@/stores/authStore";
import DestructibleAlert from "@/components/DestructibleAlert";
interface LinkedView { const HomePage = () => {
id: string;
content: ReactNode;
}
const page = () => {
const router = useRouter(); const router = useRouter();
const [boardData, setBoardData] = useState([]); const [linkedViews, setLinkedViews] = useState<GalleryViews[]>();
const [boardError, setBoardError] = useState(null); const { user } = useAuthStore();
const [linkedViews, setLinkedViews] = useState<LinkedView[]>();
const performanceData = [
{ label: "Mock Test", progress: 20 },
{ label: "Topic Test", progress: 70 },
{ label: "Subject Test", progress: 50 },
];
const progressData = [
{ label: "Physics", progress: 25 },
{ label: "Chemistry", progress: 57 },
];
useEffect(() => { useEffect(() => {
let isMounted = true; const fetchedLinkedViews: GalleryViews[] = getLinkedViews();
async function fetchBoardData() {
try {
const response = await fetch(`${API_URL}/leaderboard`);
if (!response.ok) {
throw new Error("Failed to fetch leaderboard data");
}
const data = await response.json();
if (isMounted) setBoardData(data);
} catch (error) {
if (isMounted) setBoardError(error.message || "An error occurred");
}
}
const fetchedLinkedViews = getLinkedViews();
setLinkedViews(fetchedLinkedViews); setLinkedViews(fetchedLinkedViews);
fetchBoardData();
return () => {
isMounted = false;
};
}, []); }, []);
return ( return (
<BackgroundWrapper> <BackgroundWrapper>
<div className={styles.container}> <div className="flex-1 min-h-screen">
<Header displayUser /> <Header displayUser />
<div className={styles.scrollContainer}> <div className="overflow-y-auto pt-4 h-[calc(100vh-80px)]">
<div className={styles.contentWrapper}> <div className="pb-40 mx-6">
{!user?.is_verified && (
<DestructibleAlert
text="Please verify your account. Check your email for the verification link."
variant="warning"
/>
)}
<SlidingGallery views={linkedViews} height="23vh" /> <SlidingGallery views={linkedViews} height="23vh" />
<div className={styles.mainContent}> <div className="flex flex-col gap-9">
{/* Categories Section */} {/* Categories Section */}
<div> <div>
<div className={styles.sectionHeader}> <div className="flex item-scenter justify-between">
<h2 className={styles.sectionTitle}>Categories</h2> <h2 className="text-2xl font-bold text-[#113768]">
Categories
</h2>
<button <button
onClick={() => router.push("/categories")} onClick={() => router.push("/categories")}
className={styles.arrowButton} className={styles.arrowButton}
@ -78,67 +50,67 @@ const page = () => {
<ChevronRight size={24} color="#113768" /> <ChevronRight size={24} color="#113768" />
</button> </button>
</div> </div>
<div className={styles.categoriesContainer}> <div className="grid grid-cols-2 gap-4 pt-6 ">
<div className={styles.categoryRow}> <button
<button onClick={() => router.push("/categories/topics")}
disabled className="flex flex-col justify-center items-center border-[1px] border-blue-200 aspect-square rounded-3xl gap-2 bg-white"
className={`${styles.categoryButton} ${styles.disabled}`} >
> <Image
<Image src="/images/icons/topic-test.png"
src="/images/icons/topic-test.png" alt="Topic Test"
alt="Topic Test" width={85}
width={85} height={85}
height={85} />
/> <span className="font-medium text-[#113768]">
<span className={styles.categoryButtonText}> Topic Test
Topic Test </span>
</span> </button>
</button>
<button <button
onClick={() => router.push("/unit")} onClick={() => router.push("/categories/mocks")}
className={styles.categoryButton} className="flex flex-col justify-center items-center border-[1px] border-blue-200 aspect-square rounded-3xl gap-2 bg-white"
> >
<Image <Image
src="/images/icons/mock-test.png" src="/images/icons/mock-test.png"
alt="Mock Test" alt="Mock Test"
width={85} width={85}
height={85} height={85}
/> />
<span className={styles.categoryButtonText}> <span className="font-medium text-[#113768]">
Mock Test Mock Test
</span> </span>
</button> </button>
</div>
<div className={styles.categoryRow}> <button
<button disabled
disabled className="flex flex-col justify-center items-center border-[1px] border-blue-200 aspect-square rounded-3xl gap-2 bg-white"
className={`${styles.categoryButton} ${styles.disabled}`} >
> <Image
<Image src="/images/icons/past-paper.png"
src="/images/icons/past-paper.png" alt="Past Papers"
alt="Past Papers" width={68}
width={68} height={68}
height={68} className="opacity-50"
/> />
<span className={styles.categoryButtonText}> <span className="font-medium text-[#113768]/50">
Past Papers Past Papers
</span> </span>
</button> </button>
<button
disabled <button
className={`${styles.categoryButton} ${styles.disabled}`} onClick={() => router.push("/categories/subjects")}
> className="flex flex-col justify-center items-center border-[1px] border-blue-200 aspect-square rounded-3xl gap-2 bg-white"
<Image >
src="/images/icons/subject-test.png" <Image
alt="Subject Test" src="/images/icons/subject-test.png"
width={80} alt="Subject Test"
height={80} width={80}
/> height={80}
<span className={styles.categoryButtonText}> />
Subject Test <span className="font-medium text-[#113768]">
</span> Subject Test
</button> </span>
</div> </button>
</div> </div>
</div> </div>
@ -157,20 +129,25 @@ const page = () => {
</div> </div>
<div className={styles.divider}></div> <div className={styles.divider}></div>
<div className={styles.topThreeList}> <div className={styles.topThreeList}>
{getTopThree(boardData).map((student, idx) => ( {/* {boardError ? (
<div key={idx} className={styles.topThreeItem}> <DestructibleAlert text={boardError} />
<div className={styles.studentInfo}> ) : (
<span className={styles.rank}>{student.rank}</span> getTopThree(boardData).map((student, idx) => (
<Avatar className="bg-slate-300 w-4 h-4 rounded-full"></Avatar> <div key={idx} className={styles.topThreeItem}>
<span className={styles.studentName}> <div className={styles.studentInfo}>
{student.name} <span className={styles.rank}>{student.rank}</span>
<Avatar className="bg-slate-300 w-4 h-4 rounded-full" />
<span className={styles.studentName}>
{student.name}
</span>
</div>
<span className={styles.points}>
{student.points}pt
</span> </span>
</div> </div>
<span className={styles.points}> ))
{student.points}pt )} */}
</span> <h2 className="text-center text-xl">Coming soon</h2>
</div>
))}
</div> </div>
</div> </div>
</div> </div>
@ -232,4 +209,4 @@ const page = () => {
); );
}; };
export default page; export default HomePage;

View File

@ -5,13 +5,20 @@ import Link from "next/link";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import clsx from "clsx"; import clsx from "clsx";
import { House, LayoutGrid, Bookmark, Settings } from "lucide-react"; import { GoHomeFill } from "react-icons/go";
import { HiViewGrid } from "react-icons/hi";
import { FaBookmark } from "react-icons/fa";
import { HiCog6Tooth } from "react-icons/hi2";
const tabs = [ const tabs = [
{ name: "Home", href: "/home", component: <House size={30} /> }, { name: "Home", href: "/home", component: <GoHomeFill size={30} /> },
{ name: "Unit", href: "/unit", component: <LayoutGrid size={30} /> }, {
{ name: "Bookmark", href: "/bookmark", component: <Bookmark size={30} /> }, name: "Categories",
{ name: "Settings", href: "/settings", component: <Settings size={30} /> }, href: "/categories",
component: <HiViewGrid size={30} />,
},
{ name: "Bookmark", href: "/bookmark", component: <FaBookmark size={23} /> },
{ name: "Settings", href: "/settings", component: <HiCog6Tooth size={30} /> },
]; ];
export default function TabsLayout({ children }: { children: ReactNode }) { export default function TabsLayout({ children }: { children: ReactNode }) {

View File

@ -1,187 +1,9 @@
"use client"; "use client";
import BackgroundWrapper from "@/components/BackgroundWrapper"; import React from "react";
import Header from "@/components/Header";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { API_URL, getToken } from "@/lib/auth";
import { BoardData, getLeaderboard } from "@/lib/leaderboard";
import { UserData } from "@/types/auth";
import React, { useEffect, useState } from "react";
const LeaderboardPage = () => { const LeaderboardPage = () => {
const [boardError, setBoardError] = useState<string | null>(null); return <></>;
const [boardData, setBoardData] = useState<BoardData[]>([]);
const [userData, setUserData] = useState<UserData>({
name: "",
institution: "",
sscRoll: "",
hscRoll: "",
email: "",
phone: "",
});
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchUser() {
try {
const token = await getToken();
if (!token) throw new Error("User is not authenticated");
const response = await fetch(`${API_URL}/me`, {
method: "get",
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error("Failed to fetch user data");
const fetchedUserData = await response.json();
setLoading(false);
setUserData(fetchedUserData);
} catch (error) {
console.error(error);
setUserData(undefined);
}
}
async function fetchBoardData() {
try {
const boardResponse = await fetch(`${API_URL}/leaderboard`, {
method: "GET",
});
if (!boardResponse.ok) {
throw new Error("Failed to fetch leaderboard data");
}
const fetchedBoardData = await boardResponse.json();
if (Array.isArray(fetchedBoardData) && fetchedBoardData.length > 0) {
setBoardData(fetchedBoardData);
} else {
setBoardError("No leaderboard data available.");
setBoardData([]);
}
} catch (error) {
console.error(error);
setBoardError("Something went wrong. Please try again.");
setBoardData([]);
}
}
fetchUser();
fetchBoardData();
}, []);
const getTopThree = (boardData: BoardData[]) => {
if (!boardData || !Array.isArray(boardData)) return [];
const sortedData = boardData
.filter((player) => player?.points !== undefined) // Ensure `points` exists
.sort((a, b) => b.points - a.points);
const topThree = sortedData.slice(0, 3).map((player, index) => ({
...player,
rank: index + 1,
height: index === 0 ? 280 : index === 1 ? 250 : 220,
}));
return [topThree[1], topThree[0], topThree[2]].filter(Boolean); // Handle missing players
};
const getUserData = (boardData: BoardData[], name: string) => {
if (!boardData || !Array.isArray(boardData)) return [];
const sortedData = boardData
.filter((player) => player?.name && player?.points !== undefined)
.sort((a, b) => b.points - a.points);
const result = sortedData.find((player) => player.name === name);
return result ? [{ ...result, rank: sortedData.indexOf(result) + 1 }] : [];
};
return (
<BackgroundWrapper>
<section>
<Header displayTabTitle={"Leaderboard"} />
<section className="flex flex-col mx-10 pt-10 space-y-4">
<section className="flex justify-evenly items-end">
{getTopThree(boardData).map((student, idx) =>
student ? (
<div
key={idx}
className="w-[100px] flex flex-col bg-[#113768] rounded-t-xl items-center justify-start pt-4 space-y-3"
style={{ height: student.height }}
>
<h3 className="font-bold text-xl text-white">
{student.rank}
</h3>
<Avatar className="bg-slate-300 w-12 h-12">
<AvatarFallback className="text-xl font-semibold">
{student.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="font-bold text-md text-center text-white">
{student.name}
</p>
<p className="text-sm text-white">({student.points}pt)</p>
</div>
) : null
)}
</section>
<div className="w-full border-[0.5px] border-[#c5dbf8] bg-[#c5dbf8]"></div>
<section className="border-[1px] border-[#c0dafc] w-full rounded-3xl p-6 space-y-4 mb-20">
<section>
{getUserData(boardData, userData.name).map((user, idx) => (
<div
key={idx}
className="flex bg-[#113768] rounded-[8] py-2 px-4 justify-between items-center"
>
<div className=" flex gap-3 items-center">
<h2 className="font-medium text-sm text-white">
{user.rank}
</h2>
<Avatar className="bg-slate-300 w-6 h-6">
<AvatarFallback className="text-sm font-semibold">
{user.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<h3 className="font-medium text-sm text-white">You</h3>
</div>
<p className="font-medium text-white/70 text-sm">
{user.points}pt
</p>
</div>
))}
</section>
<div className="w-full border-[0.5px] border-[#c5dbf8] bg-[#c5dbf8]"></div>
<section className="space-y-4">
{getLeaderboard(boardData)
.slice(0, 10)
.map((user, idx) => (
<div
key={idx}
className="flex border-2 border-[#c5dbf8] rounded-[8] py-2 px-4 justify-between items-center"
>
<div className="flex gap-3 items-center">
<h2 className="font-medium text-sm">{idx + 1}</h2>
<Avatar className="bg-slate-300 w-6 h-6">
<AvatarFallback className="text-sm font-semibold">
{user.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<h3 className="font-medium text-sm">
{user.name.split(" ").slice(0, 2).join(" ")}
</h3>
</div>
<p className="font-medium text-[#000]/40 text-sm">
{user.points}pt
</p>
</div>
))}
</section>
</section>
</section>
</section>
</BackgroundWrapper>
);
}; };
export default LeaderboardPage; export default LeaderboardPage;

View File

@ -1,126 +0,0 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Header from "@/components/Header";
import DestructibleAlert from "@/components/DestructibleAlert";
import BackgroundWrapper from "@/components/BackgroundWrapper";
import { API_URL } from "@/lib/auth";
import { Loader, RefreshCw } from "lucide-react";
interface Mock {
id: string;
title: string;
rating: number;
}
export default function PaperScreen() {
const router = useRouter();
const searchParams = useSearchParams();
const name = searchParams.get("name") || "";
const [questions, setQuestions] = useState<Mock[] | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [componentKey, setComponentKey] = useState<number>(0);
async function fetchMocks() {
try {
const questionResponse = await fetch(`${API_URL}/mocks`, {
method: "GET",
});
const fetchedQuestionData: Mock[] = await questionResponse.json();
setQuestions(fetchedQuestionData);
} catch (error) {
setErrorMsg(error instanceof Error ? error.message : "An error occurred");
}
}
useEffect(() => {
if (name) {
fetchMocks();
}
}, [name]);
const onRefresh = async () => {
setRefreshing(true);
await fetchMocks();
setComponentKey((prevKey) => prevKey + 1);
setTimeout(() => {
setRefreshing(false);
}, 1000);
};
if (errorMsg) {
return (
<BackgroundWrapper>
<div className="min-h-screen">
<Header displayTabTitle={name} />
<div className="overflow-y-auto">
<div className="mt-5 px-5">
<DestructibleAlert text={errorMsg} extraStyles="" />
</div>
<div className="flex justify-center mt-4">
<button
onClick={onRefresh}
disabled={refreshing}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{refreshing ? <Loader /> : <RefreshCw />}
</button>
</div>
</div>
{/* <CustomBackHandler fallbackRoute="unit" /> */}
</div>
</BackgroundWrapper>
);
}
return (
<BackgroundWrapper>
<div>
<Header displayTabTitle={name} />
<div className="mx-10 pt-10 overflow-y-auto">
<div className="border border-[#c0dafc] flex flex-col gap-4 w-full rounded-[25px] p-4">
{questions ? (
questions.map((mock) => (
<div key={mock.id}>
<button
onClick={() =>
router.push(
`/exam/pretest?unitname=${name}&id=${mock.id}&title=${mock.title}&rating=${mock.rating}`
)
}
className="w-full border-2 border-[#B0C2DA] py-4 rounded-[10px] px-6 gap-2 text-left hover:bg-gray-50 transition-colors"
>
<h3 className="text-xl font-medium">{mock.title}</h3>
<p className="text-md font-normal">
Rating: {mock.rating} / 10
</p>
</button>
</div>
))
) : (
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p className="text-xl font-medium text-center">Loading...</p>
</div>
)}
</div>
<div className="flex justify-center mt-4">
<button
onClick={onRefresh}
disabled={refreshing}
className="p-2 bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 rounded-full"
>
{refreshing ? <Loader /> : <RefreshCw />}
</button>
</div>
</div>
</div>
{/* <CustomBackHandler fallbackRoute="unit" useCustomHandler={false} /> */}
</BackgroundWrapper>
);
}

View File

@ -3,7 +3,14 @@
import ProfileManager from "@/components/ProfileManager"; import ProfileManager from "@/components/ProfileManager";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { getToken, API_URL } from "@/lib/auth"; import { getToken, API_URL } from "@/lib/auth";
import { ChevronLeft, Edit2, Lock, Save } from "lucide-react"; import {
BadgeCheck,
ChevronLeft,
Edit2,
Lock,
Save,
ShieldX,
} from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { UserData } from "@/types/auth"; import { UserData } from "@/types/auth";
@ -20,7 +27,7 @@ const ProfilePage = () => {
const token = await getToken(); const token = await getToken();
if (!token) return; if (!token) return;
const response = await fetch(`${API_URL}/me`, { const response = await fetch(`${API_URL}/me/profile/`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@ -79,11 +86,28 @@ const ProfilePage = () => {
<div className="relative mx-10"> <div className="relative mx-10">
<Avatar className="bg-[#113768] w-32 h-32 absolute -top-20 left-1/2 transform -translate-x-1/2"> <Avatar className="bg-[#113768] w-32 h-32 absolute -top-20 left-1/2 transform -translate-x-1/2">
<AvatarFallback className="text-3xl text-white"> <AvatarFallback className="text-3xl text-white">
{userData?.name ? userData.name.charAt(0).toUpperCase() : ""} {userData?.username
? userData.username.charAt(0).toUpperCase()
: ""}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="pt-14 space-y-8"> <div className="pt-14 space-y-4">
{userData?.is_verified ? (
<div className="flex gap-4 justify-center items-center bg-green-200 border border-green-700 px-3 py-4 rounded-2xl ">
<BadgeCheck size={30} />
<p className="text-sm font-semibold text-black">
This account is verified.
</p>
</div>
) : (
<div className="flex gap-2 justify-center items-center bg-red-200 border border-red-700 px-3 py-4 rounded-2xl ">
<ShieldX size={30} />
<p className="text-sm font-semibold text-black">
This account is not verified.
</p>
</div>
)}
<ProfileManager <ProfileManager
userData={userData} userData={userData}
edit={editStatus} edit={editStatus}

View File

@ -2,8 +2,9 @@
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 { API_URL, getToken } from "@/lib/auth"; import { useAuthStore } from "@/stores/authStore";
import { import {
BadgeCheck,
Bookmark, Bookmark,
ChartColumn, ChartColumn,
ChevronRight, ChevronRight,
@ -18,36 +19,16 @@ import {
UserCircle2, UserCircle2,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react"; import React from "react";
const SettingsPage = () => { const SettingsPage = () => {
const router = useRouter(); const router = useRouter();
const [userData, setUserData] = useState(null); const { user, logout } = useAuthStore();
useEffect(() => { function handleLogout() {
async function fetchUser() { logout();
try { router.replace("/");
const token = await getToken(); }
if (!token) return;
const response = await fetch(`${API_URL}/me`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const fetchedUserData = await response.json();
setUserData(fetchedUserData);
}
} catch (error) {
console.error("Error fetching user data: ", error);
}
}
fetchUser();
}, []);
return ( return (
<BackgroundWrapper> <BackgroundWrapper>
@ -63,22 +44,39 @@ const SettingsPage = () => {
<UserCircle2 size={30} color="#113768" /> <UserCircle2 size={30} color="#113768" />
</button> </button>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex items-center gap-4 bg-blue-100 border-1 border-blue-300/40 rounded-2xl py-5 px-4">
<div className="flex gap-4 items-center"> <Avatar className="bg-[#113768] w-20 h-20">
<Avatar className="bg-[#113768] w-20 h-20"> <AvatarFallback className="text-3xl text-white">
<AvatarFallback className="text-3xl text-white"> {user?.username
{userData?.name ? user.username.charAt(0).toUpperCase()
? userData.name.charAt(0).toUpperCase() : "User"}
: ""} </AvatarFallback>
</AvatarFallback> </Avatar>
</Avatar> <div className="flex flex-col items-start">
<div className="flex flex-col items-start"> <h1 className="font-semibold text-2xl flex gap-1 items-center">
<h1 className="font-semibold text-2xl">{userData?.name}</h1> {user?.full_name}
<h3 className=" text-md">{userData?.email}</h3> </h1>
</div> <h3 className=" text-md">{user?.email}</h3>
</div> </div>
</section> </section>
<section className="flex flex-col gap-8"> <section className="flex flex-col gap-8">
{!user?.is_verified && (
<>
<button
onClick={() => router.push("/settings/verify")}
className="flex justify-between"
>
<div className="flex items-center gap-4">
<BadgeCheck size={30} color="#113768" />
<h3 className="text-md font-medium text-[#113768]">
Verify your account
</h3>
</div>
<ChevronRight size={30} color="#113768" />
</button>
<div className="h-[0.5px] border-[0.1px] w-full border-[#113768]/20"></div>
</>
)}
<button <button
onClick={() => router.push("/profile")} onClick={() => router.push("/profile")}
className="flex justify-between" className="flex justify-between"
@ -165,7 +163,7 @@ const SettingsPage = () => {
</div> </div>
<ChevronRight size={30} color="#113768" /> <ChevronRight size={30} color="#113768" />
</div> </div>
<div className="flex justify-between"> <button onClick={handleLogout} className="flex justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<LogOut size={30} color="#113768" /> <LogOut size={30} color="#113768" />
<h3 className="text-md font-medium text-[#113768]"> <h3 className="text-md font-medium text-[#113768]">
@ -173,7 +171,7 @@ const SettingsPage = () => {
</h3> </h3>
</div> </div>
<ChevronRight size={30} color="#113768" /> <ChevronRight size={30} color="#113768" />
</div> </button>
<div className="h-[0.5px] border-[0.1px] w-full border-[#113768]/20"></div> <div className="h-[0.5px] border-[0.1px] w-full border-[#113768]/20"></div>
<p className="text-center text-[#113768]/50 font-medium"> <p className="text-center text-[#113768]/50 font-medium">
ExamJam | Version 1.0 ExamJam | Version 1.0

View File

@ -0,0 +1,93 @@
"use client";
import BackgroundWrapper from "@/components/BackgroundWrapper";
import Header from "@/components/Header";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { API_URL } from "@/lib/auth";
import Image from "next/image";
import React, { useState } from "react";
const VerificationScreen = () => {
const [otp, setOtp] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleVerify = async () => {
if (otp.length < 6) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_URL}/auth/verify?code=${otp}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Verification failed");
}
// 🔥 Call zustand action to update auth state
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<BackgroundWrapper>
<Header displayTabTitle="Verification" />
<div className="flex flex-col items-center justify-center pt-10 px-6 gap-4">
<Image
src={"/images/icons/otp.svg"}
height={200}
width={300}
alt="otp-banner"
/>
<h1 className="font-medium text-xl text-center ">
Enter the code here that you received in your email.
</h1>
<InputOTP
maxLength={6}
value={otp}
onChange={setOtp}
onComplete={handleVerify} // auto-submit when complete
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
{error && <p className="text-red-500 mt-3">{error}</p>}
<button
onClick={handleVerify}
disabled={otp.length < 6 || loading}
className="mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg disabled:bg-gray-400 transition"
>
{loading ? "Verifying..." : "Verify"}
</button>
</div>
</BackgroundWrapper>
);
};
export default VerificationScreen;

View File

@ -1,63 +0,0 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import Header from "@/components/Header";
import BackgroundWrapper from "@/components/BackgroundWrapper";
const units = [
{
id: 3,
name: "C Unit (Business Studies)",
rating: 9,
},
];
const Unit = () => {
const router = useRouter();
const handleUnitPress = (unit) => {
router.push(`/paper?name=${encodeURIComponent(unit.name)}`);
};
return (
<BackgroundWrapper>
<div className="flex flex-col min-h-screen">
<Header
displayExamInfo={null}
displayTabTitle={"Units"}
displaySubject={null}
displayUser={false}
title=""
image={""}
/>
<div className="flex-1">
<div className="overflow-y-auto">
<div className="border border-blue-200 gap-4 rounded-3xl p-4 mx-10 mt-10">
{units ? (
units.map((unit) => (
<button
key={unit.id}
onClick={() => handleUnitPress(unit)}
className="border-2 border-blue-300 py-4 rounded-xl px-6 gap-2 block w-full text-left hover:bg-blue-50 transition-colors duration-200"
>
<p className="text-lg font-medium">{unit.name}</p>
<p className="text-sm">Rating: {unit.rating} / 10</p>
</button>
))
) : (
<div className="flex flex-col items-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mb-4"></div>
<p className="font-bold text-2xl text-center">Loading...</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* <CustomBackHandler fallbackRoute="home" useCustomHandler={false} /> */}
</BackgroundWrapper>
);
};
export default Unit;

View File

@ -1,366 +0,0 @@
"use client";
import React, { useEffect, useState, useCallback, useMemo } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTimer } from "@/context/TimerContext";
import { useExam } from "@/context/ExamContext";
import { API_URL, getToken } from "@/lib/auth";
import Header from "@/components/Header";
import { Bookmark, BookmarkCheck } from "lucide-react";
import { useModal } from "@/context/ModalContext";
import Modal from "@/components/ExamModal";
import { Question } from "@/types/exam";
import QuestionItem from "@/components/QuestionItem";
// Types
// interface Question {
// id: number;
// question: string;
// options: Record<string, string>;
// }
// interface QuestionItemProps {
// question: Question;
// selectedAnswer?: string;
// handleSelect: (questionId: number, option: string) => void;
// }
// const QuestionItem = React.memo<QuestionItemProps>(
// ({ question, selectedAnswer, handleSelect }) => {
// const [bookmark, setBookmark] = useState(false);
// return (
// <div className="border-[0.5px] border-[#8abdff]/60 rounded-2xl p-4 flex flex-col">
// <h3 className="text-xl font-medium mb-[20px]">
// {question.id}. {question.question}
// </h3>
// <div className="flex justify-between items-center">
// <div></div>
// <button onClick={() => setBookmark(!bookmark)}>
// {bookmark ? (
// <BookmarkCheck size={25} color="#113768" />
// ) : (
// <Bookmark size={25} color="#113768" />
// )}
// </button>
// </div>
// <div className="flex flex-col gap-4 items-start">
// {Object.entries(question.options).map(([key, value]) => (
// <button
// key={key}
// className="flex items-center gap-3"
// onClick={() => handleSelect(question.id, key)}
// >
// <span
// className={`flex items-center rounded-full border px-1.5 ${
// selectedAnswer === key
// ? "text-white bg-[#113768] border-[#113768]"
// : ""
// }`}
// >
// {key.toUpperCase()}
// </span>
// <span className="option-description">{value}</span>
// </button>
// ))}
// </div>
// </div>
// );
// }
// );
// QuestionItem.displayName = "QuestionItem";
export default function ExamPage() {
// All hooks at the top - no conditional calls
const router = useRouter();
const { id } = useParams();
const time = useSearchParams().get("time");
const { isOpen, close } = useModal();
const { setInitialTime, stopTimer } = useTimer();
const {
currentAttempt,
setAnswer,
getAnswer,
submitExam: submitExamContext,
setApiResponse,
isExamStarted,
isExamCompleted,
isHydrated,
isInitialized,
currentExam,
} = useExam();
// State management
const [questions, setQuestions] = useState<Question[] | null>(null);
const [loading, setLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submissionLoading, setSubmissionLoading] = useState(false);
const [componentState, setComponentState] = useState<
"loading" | "redirecting" | "ready"
>("loading");
// Combined initialization effect
useEffect(() => {
let mounted = true;
const initializeComponent = async () => {
// Wait for hydration and initialization
if (!isHydrated || !isInitialized || isSubmitting) {
return;
}
// Check exam state and handle redirects
if (!isExamStarted()) {
if (mounted) {
setComponentState("redirecting");
setTimeout(() => {
if (mounted) router.push("/unit");
}, 100);
}
return;
}
if (isExamCompleted()) {
if (mounted) {
setComponentState("redirecting");
setTimeout(() => {
if (mounted) router.push("/exam/results");
}, 100);
}
return;
}
// Component is ready to render
if (mounted) {
setComponentState("ready");
}
};
initializeComponent();
return () => {
mounted = false;
};
}, [
isHydrated,
isInitialized,
isExamStarted,
isExamCompleted,
isSubmitting,
router,
]);
// Fetch questions effect
useEffect(() => {
if (componentState !== "ready") return;
const fetchQuestions = async () => {
try {
const response = await fetch(`${API_URL}/mock/${id}`);
const data = await response.json();
setQuestions(data.questions);
} catch (error) {
console.error("Error fetching questions:", error);
} finally {
setLoading(false);
}
};
fetchQuestions();
if (time) setInitialTime(Number(time));
}, [id, time, setInitialTime, componentState]);
const handleSelect = useCallback(
(questionId: number, option: string) => {
setAnswer(questionId.toString(), option);
},
[setAnswer]
);
const handleSubmit = async () => {
if (!currentAttempt) return console.error("No exam attempt found");
stopTimer();
setSubmissionLoading(true);
setIsSubmitting(true);
const answersForAPI = currentAttempt.answers.reduce(
(acc, { questionId, answer }) => {
acc[+questionId] = answer;
return acc;
},
{} as Record<number, string>
);
try {
const response = await fetch(`${API_URL}/submit`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${await getToken()}`,
},
body: JSON.stringify({ mock_id: id, data: answersForAPI }),
});
if (!response.ok)
throw new Error((await response.json()).message || "Submission failed");
const responseData = await response.json();
submitExamContext();
setApiResponse(responseData);
router.push("/exam/results");
} catch (error) {
console.error("Error submitting answers:", error);
setIsSubmitting(false);
} finally {
setSubmissionLoading(false);
}
};
const showExitDialog = useCallback(() => {
if (window.confirm("Are you sure you want to quit the exam?")) {
stopTimer();
router.push("/unit");
}
}, [stopTimer, router]);
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
const handlePopState = (e: PopStateEvent) => {
e.preventDefault();
showExitDialog();
};
window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
};
}, [showExitDialog]);
const answeredSet = useMemo(() => {
if (!currentAttempt) return new Set<string>();
return new Set(currentAttempt.answers.map((a) => String(a.questionId)));
}, [currentAttempt]);
// Show loading/redirecting state
if (componentState === "loading" || componentState === "redirecting") {
const loadingText =
componentState === "redirecting" ? "Redirecting..." : "Loading...";
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900 mb-4"></div>
<p className="text-lg font-medium text-gray-900">{loadingText}</p>
</div>
</div>
);
}
// Show submission loading
if (submissionLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900 mb-4"></div>
<p className="text-lg font-medium text-gray-900">Submitting...</p>
</div>
</div>
);
}
// Render the main exam interface
return (
<div className="min-h-screen bg-gray-50">
<Header
examDuration={time}
displayTabTitle={null}
image={undefined}
displayUser={undefined}
displaySubject={undefined}
/>
<Modal open={isOpen} onClose={close} title={currentExam?.title}>
{currentAttempt ? (
<div>
<div className="flex gap-4">
<p className="">Questions: {currentExam?.questions.length}</p>
<p className="">
Answers:{" "}
<span className="text-[#113768] font-bold">
{currentAttempt?.answers.length}
</span>
</p>
<p className="">
Skipped:{" "}
<span className="text-yellow-600 font-bold">
{currentExam?.questions.length -
currentAttempt?.answers.length}
</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">
{currentExam?.questions.map((q, idx) => {
const answered = answeredSet.has(String(q.id));
return (
<div
key={q.id ?? idx}
className={`h-16 w-16 rounded-full flex items-center justify-center
text-2xl
${
answered
? "bg-[#0E2C53] text-white font-semibold"
: "bg-[#E9EDF1] text-black font-normal"
}`}
>
{idx + 1}
</div>
);
})}
</section>
</div>
) : (
<p>No attempt data.</p>
)}
</Modal>
<div className="container mx-auto px-6 py-8">
{loading ? (
<div className="flex items-center justify-center min-h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900"></div>
</div>
) : (
<div className="space-y-6 mb-20">
{questions?.map((q) => (
<QuestionItem
key={q.id}
question={q}
selectedAnswer={getAnswer(q.id.toString())}
handleSelect={handleSelect}
mode="exam"
/>
))}
</div>
)}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-4">
<button
onClick={handleSubmit}
disabled={submissionLoading}
className="w-full bg-blue-900 text-white py-4 px-6 rounded-lg font-bold text-lg hover:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Submit
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,133 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Header from "@/components/Header";
import QuestionItem from "@/components/QuestionItem";
import BackgroundWrapper from "@/components/BackgroundWrapper";
import { useExamStore } from "@/stores/examStore";
import { useTimerStore } from "@/stores/timerStore";
export default function ExamPage() {
const router = useRouter();
const searchParams = useSearchParams();
const test_id = searchParams.get("test_id") || "";
const type = searchParams.get("type") || "";
const { setStatus, test, answers, startExam, setAnswer, submitExam } =
useExamStore();
const { resetTimer, stopTimer } = useTimerStore();
const [isSubmitting, setIsSubmitting] = useState(false);
// Start exam + timer automatically
useEffect(() => {
if (!type || !test_id) return;
const initExam = async () => {
const fetchedTest = await startExam(type, test_id);
if (!fetchedTest) return;
setStatus("in-progress");
const timeLimit = fetchedTest.metadata.time_limit_minutes;
if (timeLimit) {
resetTimer(timeLimit * 60, async () => {
// Auto-submit when timer ends
stopTimer();
setStatus("finished");
await submitExam(type);
router.replace("/exam/results");
});
}
};
initExam();
return () => {
stopTimer();
};
}, [
type,
test_id,
startExam,
resetTimer,
stopTimer,
submitExam,
router,
setStatus,
]);
if (isSubmitting) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900 mb-4"></div>
<p className="text-lg font-medium text-gray-900">Submitting exam...</p>
</div>
);
}
if (!test) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900 mb-4"></div>
<p className="text-lg font-medium text-gray-900">Loading exam...</p>
</div>
</div>
);
}
const handleSubmitExam = async (type: string) => {
setIsSubmitting(true);
stopTimer();
try {
const result = await submitExam(type); // throws if fails
if (!result) throw new Error("Submission failed");
router.replace("/exam/results"); // navigate
} catch (err) {
console.error("Submit exam failed:", err);
alert("Failed to submit exam. Please try again.");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header with live timer */}
<Header />
{/* Questions */}
<BackgroundWrapper>
<div className="container mx-auto px-6 py-8 mb-20">
{test.questions.map((q, idx) => (
<div id={`question-${idx}`} key={q.question_id}>
<QuestionItem
question={q}
index={idx}
selectedAnswer={answers[idx]}
onSelect={(answer) => setAnswer(idx, answer)}
/>
</div>
))}
{/* Bottom submit bar */}
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex">
<button
onClick={() => handleSubmitExam(type)}
disabled={isSubmitting}
className="flex-1 bg-blue-900 text-white p-6 font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-800 transition-colors flex justify-center items-center gap-2"
>
Submit
</button>
</div>
</div>
</BackgroundWrapper>
</div>
);
}

View File

@ -1,148 +1,189 @@
"use client"; "use client";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { ArrowLeft, HelpCircle, Clock, XCircle } from "lucide-react"; import {
ArrowLeft,
HelpCircle,
Clock,
XCircle,
OctagonX,
RotateCw,
} from "lucide-react";
import DestructibleAlert from "@/components/DestructibleAlert"; import DestructibleAlert from "@/components/DestructibleAlert";
import BackgroundWrapper from "@/components/BackgroundWrapper"; import BackgroundWrapper from "@/components/BackgroundWrapper";
import { API_URL } from "@/lib/auth"; import { API_URL, getToken } from "@/lib/auth";
import { useExam } from "@/context/ExamContext"; import { Metadata } from "@/types/exam";
import { Exam } from "@/types/exam"; import { useExamStore } from "@/stores/examStore";
import { FaStar } from "react-icons/fa";
interface Metadata { function PretestPageContent() {
metadata: {
quantity: number;
type: string;
duration: number;
marking: string;
};
}
export default function PretestPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [examData, setExamData] = useState<Exam>();
const { startExam, setCurrentExam } = useExam();
// Get params from URL search params // Get params from URL search params
const name = searchParams.get("name") || ""; const id = searchParams.get("test_id") || "";
const id = searchParams.get("id") || ""; const type = searchParams.get("type");
const title = searchParams.get("title") || "";
const rating = searchParams.get("rating") || "";
const [metadata, setMetadata] = useState<Metadata | null>(null); const [metadata, setMetadata] = useState<Metadata | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string>();
const { setStatus } = useExamStore();
async function fetchQuestions() {
if (!id) return;
try {
setLoading(true);
const questionResponse = await fetch(`${API_URL}/mock/${id}`, {
method: "GET",
});
if (!questionResponse.ok) {
throw new Error("Failed to fetch questions");
}
const data = await questionResponse.json();
const fetchedMetadata: Metadata = data;
setExamData(data);
setMetadata(fetchedMetadata);
} catch (error) {
console.error(error);
setError(error instanceof Error ? error.message : "An error occurred");
} finally {
setLoading(false);
}
}
useEffect(() => { useEffect(() => {
if (id) { async function fetchQuestions() {
fetchQuestions(); if (!id || !type) return;
try {
setLoading(true);
const token = await getToken();
const questionResponse = await fetch(`${API_URL}/tests/${type}/${id}`, {
method: "GET",
headers: {
authorization: `Bearer ${token}`,
},
});
if (!questionResponse.ok) {
throw new Error("Failed to fetch questions");
}
const data = await questionResponse.json();
const fetchedMetadata: Metadata = data.metadata;
setMetadata(fetchedMetadata);
} catch (error) {
console.error(error);
setError(error instanceof Error ? error.message : "An error occurred");
} finally {
setLoading(false);
}
} }
}, [id]);
fetchQuestions();
}, [id, type]);
if (error) { if (error) {
return (
<BackgroundWrapper>
<div className="min-h-screen mx-10 pt-10 flex flex-col justify-center items-center gap-4">
<div className="flex flex-col justify-center items-center gap-4 w-full">
<DestructibleAlert
text={error}
icon={<OctagonX size={150} color="red" />}
/>
<div className="flex items-center justify-evenly w-full">
<button
onClick={() => router.push(`/categories/${type}s`)}
className="p-4 border border-blue-200 rounded-full bg-blue-400"
>
<ArrowLeft size={35} color="white" />
</button>
<button
onClick={() => router.refresh()}
className="p-4 border border-blue-200 rounded-full bg-red-400"
>
<RotateCw size={35} color="white" />
</button>
</div>
</div>
</div>
</BackgroundWrapper>
);
}
if (loading) {
return ( return (
<BackgroundWrapper> <BackgroundWrapper>
<div className="min-h-screen"> <div className="min-h-screen">
<div className="mx-10 mt-10"> <div className="mx-10 pt-10">
<button onClick={() => router.push("/unit")} className="mb-4"> <button
onClick={() => router.push(`/categories/${type}s`)}
className="mb-4"
>
<ArrowLeft size={30} color="black" /> <ArrowLeft size={30} color="black" />
</button> </button>
<DestructibleAlert text={error} extraStyles="" /> <div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p className="text-lg font-medium text-gray-900">Loading...</p>
</div>
</div> </div>
{/* <CustomBackHandler fallbackRoute="paper" /> */}
</div> </div>
</BackgroundWrapper> </BackgroundWrapper>
); );
} }
function handleStartExam() { function handleStartExam() {
setCurrentExam(examData); if (!metadata) return;
startExam(examData); // Pass examData directly setStatus("in-progress");
router.push(`/exam/${id}?time=${metadata?.metadata.duration}`);
router.push(
`/exam/exam-screen?type=${type}&test_id=${metadata?.test_id}&attempt_id=${metadata?.attempt_id}`
);
} }
return ( return (
<BackgroundWrapper> <BackgroundWrapper>
<div className="min-h-screen flex flex-col justify-between"> <div className="min-h-screen flex flex-col justify-between">
<div className="flex-1 overflow-y-auto mb-20"> <div className="flex-1 overflow-y-auto mb-20">
{metadata ? ( {metadata ? (
<div className="mx-10 mt-10 gap-6 pb-6 space-y-6"> <div className="mx-10 mt-10 gap-6 pb-6 space-y-5">
<button onClick={() => router.back()}> <button onClick={() => router.replace(`/categories/${type}s`)}>
<ArrowLeft size={30} color="black" /> <ArrowLeft size={30} color="black" />
</button> </button>
<h1 className="text-4xl font-semibold text-[#113768]">{title}</h1> <h1 className="text-4xl font-semibold text-[#113768]">
{metadata.name}
</h1>
<p className="text-xl font-medium text-[#113768]"> <p className="text-xl font-medium text-[#113768] flex items-center gap-1">
Rating: {rating} / 10 <FaStar size={15} />
<FaStar size={15} />
<FaStar size={15} />
<FaStar size={15} />
</p> </p>
<div className="border-[1.5px] border-[#226DCE]/30 rounded-[25px] gap-8 py-7 px-5 space-y-8"> <div className="border-[1.5px] border-[#226DCE]/30 rounded-[25px] py-5 px-5 space-y-6">
<div className="flex gap-5 items-center"> <div className="flex gap-5 items-center">
<HelpCircle size={40} color="#113768" /> <HelpCircle size={52} color="#113768" />
<div className="space-y-2"> <div className="">
<p className="font-bold text-4xl text-[#113768]"> <p className="font-bold text-3xl text-[#113768]">
{metadata.metadata.quantity} {metadata.num_questions}
</p> </p>
<p className="font-normal text-lg"> <p className="font-normal text-md">
{metadata.metadata.type} Multiple Choice Questions
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-5 items-center"> <div className="flex gap-5 items-center">
<Clock size={40} color="#113768" /> <Clock size={52} color="#113768" />
<div className="space-y-2"> <div className="">
<p className="font-bold text-4xl text-[#113768]"> <p className="font-bold text-3xl text-[#113768]">
{metadata.metadata.duration} mins {String(metadata.time_limit_minutes)} mins
</p> </p>
<p className="font-normal text-lg">Time Taken</p> <p className="font-normal text-md">Time Taken</p>
</div> </div>
</div> </div>
<div className="flex gap-5 items-center"> <div className="flex gap-5 items-center">
<XCircle size={40} color="#113768" /> <XCircle size={52} color="#113768" />
<div className="space-y-2"> <div className="">
<p className="font-bold text-4xl text-[#113768]"> <p className="font-bold text-3xl text-[#113768]">
{metadata.metadata.marking} {metadata.deduction} marks
</p> </p>
<p className="font-normal text-lg"> <p className="font-normal text-md">
From each wrong answer From each wrong answer
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="border-[1.5px] border-[#226DCE]/30 rounded-[25px] gap-4 py-7 px-5 space-y-4"> <div className="border-[1.5px] border-[#226DCE]/30 rounded-[25px] px-6 py-5 space-y-3">
<h2 className="text-xl font-bold">Ready yourself!</h2> <h2 className="text-xl font-bold">Ready yourself!</h2>
<div className="flex pr-4"> <div className="flex pr-4">
<span className="mx-4"></span> <span className="mx-4"></span>
<p className="font-normal text-lg"> <p className="font-medium text-md">
You must complete this test in one session - make sure your You must complete this test in one session - make sure your
internet connection is reliable. internet connection is reliable.
</p> </p>
@ -150,14 +191,14 @@ export default function PretestPage() {
<div className="flex pr-4"> <div className="flex pr-4">
<span className="mx-4"></span> <span className="mx-4"></span>
<p className="font-normal text-lg"> <p className="font-medium text-md">
There is negative marking for the wrong answer. There is no negative marking for the wrong answer.
</p> </p>
</div> </div>
<div className="flex pr-4"> <div className="flex pr-4">
<span className="mx-4"></span> <span className="mx-4"></span>
<p className="font-normal text-lg"> <p className="font-medium text-md">
The more you answer correctly, the better chance you have of The more you answer correctly, the better chance you have of
winning a badge. winning a badge.
</p> </p>
@ -165,7 +206,7 @@ export default function PretestPage() {
<div className="flex pr-4"> <div className="flex pr-4">
<span className="mx-4"></span> <span className="mx-4"></span>
<p className="font-normal text-lg"> <p className="font-medium text-md">
You can retake this test however many times you want. But, You can retake this test however many times you want. But,
you will earn points only once. you will earn points only once.
</p> </p>
@ -181,7 +222,7 @@ export default function PretestPage() {
</div> </div>
<button <button
onClick={async () => handleStartExam()} onClick={() => handleStartExam()}
className="fixed bottom-0 w-full bg-[#113768] h-[78px] justify-center items-center flex text-white text-2xl font-bold" className="fixed bottom-0 w-full bg-[#113768] h-[78px] justify-center items-center flex text-white text-2xl font-bold"
> >
Start Test Start Test
@ -192,3 +233,22 @@ export default function PretestPage() {
</BackgroundWrapper> </BackgroundWrapper>
); );
} }
export default function PretestPage() {
return (
<Suspense
fallback={
<BackgroundWrapper>
<div className="min-h-screen">
<div className="mx-10 mt-10 flex flex-col justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900 mb-4"></div>
<p className="text-lg font-medium text-gray-900">Loading...</p>
</div>
</div>
</BackgroundWrapper>
}
>
<PretestPageContent />
</Suspense>
);
}

View File

@ -1,119 +1,98 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useExam, useExamResults } from "@/context/ExamContext"; import React, { useCallback, useEffect } from "react";
import { useEffect, useState } from "react";
import React from "react";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import SlidingGallery from "@/components/SlidingGallery"; import { useExamStore } from "@/stores/examStore";
import QuestionItem from "@/components/QuestionItem"; import QuestionItem from "@/components/QuestionItem";
import SlidingGallery from "@/components/SlidingGallery";
import { getResultViews } from "@/lib/gallery-views"; import { getResultViews } from "@/lib/gallery-views";
export default function ResultsPage() { export default function ResultsPage() {
const router = useRouter(); const router = useRouter();
const { const { result, clearResult, setStatus, status } = useExamStore();
clearExam,
isExamCompleted,
getApiResponse,
currentAttempt,
isHydrated,
} = useExam();
const [isLoading, setIsLoading] = useState(true); const handleBackToHome = useCallback(() => {
clearResult();
router.replace("/categories");
}, [clearResult, router]);
useEffect(() => { useEffect(() => {
// Wait for hydration first const handlePopState = () => {
if (!isHydrated) return; if (status !== "finished") {
handleBackToHome();
}
};
// Check if exam is completed, redirect if not window.addEventListener("popstate", handlePopState);
if (!isExamCompleted() || !currentAttempt) { return () => {
router.push("/unit"); window.removeEventListener("popstate", handlePopState);
return; };
} }, [status, router, setStatus, handleBackToHome]);
// If we have exam results, we're ready to render if (!result) {
if (currentAttempt?.answers) {
setIsLoading(false);
}
}, [isExamCompleted, currentAttempt, isHydrated, router]);
const handleBackToHome = () => {
clearExam();
router.push("/unit");
};
// Show loading screen while initializing or if no exam results
if (isLoading || !currentAttempt) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<div className="text-center"> <p className="text-lg font-medium">Redirecting...</p>
<div className="mt-60 flex flex-col items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p className="text-xl font-medium text-center">Loading...</p>
</div>
</div>
</div> </div>
); );
} }
const apiResponse = getApiResponse(); const views = getResultViews(result);
const timeTaken =
currentAttempt.endTime && currentAttempt.startTime
? Math.round(
(currentAttempt.endTime.getTime() -
currentAttempt.startTime.getTime()) /
1000 /
60
)
: 0;
const views = getResultViews(currentAttempt);
// Get score-based message
const getScoreMessage = () => {
if (!currentAttempt.score || currentAttempt.score < 30)
return "Try harder!";
if (currentAttempt.score < 70) return "Getting Better";
return "You did great!";
};
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<button className="p-10" onClick={handleBackToHome}> <button className="px-10 pt-10" onClick={handleBackToHome}>
<ArrowLeft size={30} color="black" /> <ArrowLeft size={30} color="black" />
</button> </button>
<div className="bg-white rounded-lg shadow-lg px-10 pb-20"> <div className="bg-white rounded-lg shadow-lg px-10 pb-20">
<h1 className="text-2xl font-bold text-[#113768] mb-4 text-center"> <h1 className="text-2xl font-bold text-[#113768] text-center">
{getScoreMessage()} You did great!
</h1> </h1>
{/* Score Display */} <SlidingGallery views={views} height={"26vh"} />
<SlidingGallery className="my-8" views={views} height="170px" />
{apiResponse?.questions && ( {/* Render questions with correctness */}
<div className="mb-8"> {result.user_questions.map((q, idx) => {
<h3 className="text-2xl font-bold text-[#113768] mb-4"> const userAnswer = result.user_answers[idx];
Solutions const correctAnswer = result.correct_answers[idx];
</h3>
<div className="flex flex-col gap-7"> return (
{apiResponse.questions.map((question) => ( <div key={q.question_id} className="rounded-3xl mb-6">
<QuestionItem <QuestionItem
key={question.id} question={q}
question={question} index={idx}
selectedAnswer={currentAttempt.answers[question.id - 1]} selectedAnswer={userAnswer}
mode="result" onSelect={() => {}}
/> userAnswer={userAnswer}
))} correctAnswer={correctAnswer}
showResults={true}
/>
{/* Optional answer feedback below the question */}
{/* <div className="mt-2 text-sm">
{userAnswer === null ? (
<span className="text-yellow-600 font-medium">
Skipped — Correct: {String.fromCharCode(65 + correctAnswer)}
</span>
) : userAnswer === correctAnswer ? (
<span className="text-green-600 font-medium">Correct</span>
) : (
<span className="text-red-600 font-medium">
Your Answer: {String.fromCharCode(65 + userAnswer)} |
Correct Answer: {String.fromCharCode(65 + correctAnswer)}
</span>
)}
</div> */}
</div> </div>
</div> );
)} })}
</div> </div>
<button <button
onClick={handleBackToHome} onClick={handleBackToHome}
className="fixed bottom-0 w-full bg-blue-900 text-white h-[74px] font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="fixed bottom-0 w-full bg-blue-900 text-white h-[74px] font-bold text-lg hover:bg-blue-800 transition-colors"
> >
Finish Finish
</button> </button>

View File

@ -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>
); );

View File

@ -53,7 +53,7 @@ export default function Home() {
className="text-center font-medium" className="text-center font-medium"
style={{ fontFamily: "Montserrat, sans-serif" }} style={{ fontFamily: "Montserrat, sans-serif" }}
> >
Don't have an account?{" "} Don&apos;t have an account?
<Link href="/register" className="text-[#276ac0] hover:underline"> <Link href="/register" className="text-[#276ac0] hover:underline">
Register here Register here
</Link> </Link>

View File

@ -1,18 +1,7 @@
"use client"; "use client";
import { ExamProvider } from "@/context/ExamContext";
import { TimerProvider } from "@/context/TimerContext";
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>
);
} }

28
bash.exe.stackdump Normal file
View File

@ -0,0 +1,28 @@
Stack trace:
Frame Function Args
0007FFFFB730 00021006118E (00021028DEE8, 000210272B3E, 0007FFFFB730, 0007FFFFA630) msys-2.0.dll+0x2118E
0007FFFFB730 0002100469BA (000000000000, 000000000000, 000000000000, 0007FFFFBA08) msys-2.0.dll+0x69BA
0007FFFFB730 0002100469F2 (00021028DF99, 0007FFFFB5E8, 0007FFFFB730, 000000000000) msys-2.0.dll+0x69F2
0007FFFFB730 00021006A41E (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A41E
0007FFFFB730 00021006A545 (0007FFFFB740, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A545
0007FFFFBA10 00021006B9A5 (0007FFFFB740, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2B9A5
End of stack trace
Loaded modules:
000100400000 bash.exe
7FFA7B7E0000 ntdll.dll
7FFA7A000000 KERNEL32.DLL
7FFA78C70000 KERNELBASE.dll
7FFA7A690000 USER32.dll
7FFA79000000 win32u.dll
7FFA79860000 GDI32.dll
7FFA79490000 gdi32full.dll
7FFA79280000 msvcp_win.dll
000210040000 msys-2.0.dll
7FFA79160000 ucrtbase.dll
7FFA79630000 advapi32.dll
7FFA7B240000 msvcrt.dll
7FFA7B090000 sechost.dll
7FFA79890000 RPCRT4.dll
7FFA784C0000 CRYPTBASE.DLL
7FFA795B0000 bcryptPrimitives.dll
7FFA79FC0000 IMM32.DLL

BIN
bun.lockb

Binary file not shown.

9
capacitor.config.ts Normal file
View File

@ -0,0 +1,9 @@
import type { CapacitorConfig } from "@capacitor/cli";
const config: CapacitorConfig = {
appId: "com.examjam.omukk",
appName: "ExamJam",
webDir: "out",
};
export default config;

View File

@ -0,0 +1,49 @@
"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, token, hydrated } = useAuthStore();
const router = useRouter();
const pathname = usePathname();
// 1⃣ Run initialization once
useEffect(() => {
initializeAuth();
}, [initializeAuth]);
// 2⃣ Run routing logic only after hydration
useEffect(() => {
if (!hydrated) return;
const publicPages = ["/", "/login", "/register"];
if (token) {
if (publicPages.includes(pathname)) {
router.replace("/home");
}
} else {
if (!publicPages.includes(pathname)) {
router.replace("/login");
}
}
}, [pathname, token, hydrated, router]);
// 3⃣ Show loading until hydrated
if (!hydrated) {
return (
<div className="min-h-screen flex flex-col justify-center items-center gap-3">
<div className="animate-spin rounded-full h-20 w-20 border-b-2 border-blue-500"></div>
<p className="text-2xl font-semibold tracking-tighter">Loading...</p>
</div>
);
}
return <>{children}</>;
}

View File

@ -1,6 +1,10 @@
import React from "react"; import React, { ReactNode } from "react";
const BackgroundWrapper = ({ children }) => { interface BackgroundWrapperProps {
children: ReactNode;
}
const BackgroundWrapper = ({ children }: BackgroundWrapperProps) => {
return ( return (
<div <div
className="min-h-screen bg-cover bg-center bg-no-repeat relative" className="min-h-screen bg-cover bg-center bg-no-repeat relative"
@ -10,9 +14,7 @@ const BackgroundWrapper = ({ children }) => {
> >
<div <div
className="min-h-screen" className="min-h-screen"
style={{ style={{ backgroundColor: "rgba(0, 0, 0, 0)" }}
backgroundColor: "rgba(0, 0, 0, 0)", // Optional overlay - adjust opacity as needed
}}
> >
{children} {children}
</div> </div>

View File

@ -1,33 +1,35 @@
import React from "react"; import React, { JSX } from "react";
const DestructibleAlert = ({ interface DestructibleAlertProps {
text, variant?: "error" | "warning" | "alert";
extraStyles = "",
}: {
text: string; text: string;
extraStyles?: string; icon?: JSX.Element;
}
const DestructibleAlert: React.FC<DestructibleAlertProps> = ({
variant,
text,
icon,
}) => { }) => {
return ( return (
<div <div
className={`border bg-red-200 border-blue-200 rounded-3xl py-6 ${extraStyles}`} className={`${
style={{ variant === "error"
borderWidth: 1, ? "bg-red-200"
backgroundColor: "#fecaca", : variant === "warning"
borderColor: "#c0dafc", ? "bg-yellow-200"
paddingTop: 24, : "bg-green-200"
paddingBottom: 24, } rounded-3xl py-6 flex flex-col items-center justify-center gap-2 w-full `}
}}
> >
<div>{icon}</div>
<p <p
className="text-center text-red-800" className={`text-lg font-bold text-center ${
style={{ variant === "error"
fontSize: 17, ? "text-red-800"
lineHeight: "28px", : variant === "warning"
fontFamily: "Montserrat, sans-serif", ? "text-yellow-800"
fontWeight: "bold", : "text-green-800"
textAlign: "center", }`}
color: "#991b1b",
}}
> >
{text} {text}
</p> </p>

View File

@ -1,73 +1,61 @@
import React, { useState } from "react"; import React, { useState, useId, InputHTMLAttributes } from "react";
const FormField = ({ interface FormFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {
title: string;
value: string | number;
placeholder?: string;
handleChangeText: (value: string) => void;
}
const FormField: React.FC<FormFieldProps> = ({
title, title,
placeholder,
value, value,
placeholder,
handleChangeText, handleChangeText,
type,
...props ...props
}) => { }) => {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const inputId = useId();
const isPasswordField = title === "Password" || title === "Confirm Password"; const isPasswordField =
type === "password" || title.toLowerCase().includes("password");
const inputType = isPasswordField
? showPassword
? "text"
: "password"
: type || "text";
return ( return (
<div className="w-full"> <div className="w-full">
<label <label
className="block mb-2" htmlFor={inputId}
style={{ className="block mb-2 text-[#666666] text-[18px] font-medium font-montserrat tracking-[-0.5px]"
color: "#666666",
fontFamily: "Montserrat, sans-serif",
fontWeight: "500",
fontSize: 18,
marginBottom: 8,
letterSpacing: "-0.5px",
}}
> >
{title} {title}
</label> </label>
<div <div className="h-16 px-4 bg-[#D2DFF0] rounded-3xl flex items-center justify-between">
className="h-16 px-4 bg-blue-200 rounded-3xl flex items-center justify-between"
style={{
height: 64,
paddingLeft: 16,
paddingRight: 16,
backgroundColor: "#D2DFF0",
borderRadius: 20,
}}
>
<input <input
type={isPasswordField && !showPassword ? "password" : "text"} id={inputId}
type={inputType}
value={value} value={value}
placeholder={placeholder} placeholder={placeholder || `Enter ${title.toLowerCase()}`}
autoComplete={isPasswordField ? "current-password" : "on"}
onChange={(e) => handleChangeText(e.target.value)} onChange={(e) => handleChangeText(e.target.value)}
className="flex-1 bg-transparent outline-none border-none text-blue-950" className="flex-1 bg-transparent outline-none border-none text-blue-950 text-[16px] font-inherit"
style={{
color: "#0D47A1",
fontSize: 16,
fontFamily: "inherit",
backgroundColor: "transparent",
border: "none",
outline: "none",
}}
{...props} {...props}
/> />
{isPasswordField && ( {isPasswordField && (
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword((prev) => !prev)}
className="ml-2 text-gray-600 hover:text-gray-800 focus:outline-none" aria-pressed={showPassword}
style={{ aria-label={showPassword ? "Hide password" : "Show password"}
fontFamily: "Montserrat, sans-serif", className="ml-2 text-gray-600 hover:text-gray-800 focus:outline-none font-montserrat font-medium text-[16px]"
fontWeight: "500",
fontSize: 16,
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
}}
> >
{showPassword ? "Hide" : "Show"} {showPassword ? "Hide" : "Show"}
</button> </button>

View File

@ -1,92 +1,38 @@
import React, { useState, useEffect } from "react"; "use client";
import React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ChevronLeft, Layers } from "lucide-react"; import { ChevronLeft, Layers, RefreshCcw } from "lucide-react";
import { useTimer } from "@/context/TimerContext";
import styles from "@/css/Header.module.css";
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 { API_URL, getToken } from "@/lib/auth"; import { useAuthStore } from "@/stores/authStore";
import { UserData } from "@/types/auth"; import { useTimerStore } from "@/stores/timerStore";
import { useExamStore } from "@/stores/examStore";
import { BsPatchCheckFill } from "react-icons/bs";
interface HeaderProps { interface HeaderProps {
displayUser?: boolean; displayUser?: boolean;
displaySubject?: string; displaySubject?: string;
displayTabTitle?: string; displayTabTitle?: string;
examDuration?: string;
} }
const Header = ({ const Header = ({
displayUser, displayUser,
displaySubject, displaySubject,
displayTabTitle, displayTabTitle,
examDuration,
}: HeaderProps) => { }: HeaderProps) => {
const router = useRouter(); const router = useRouter();
const { open } = useModal(); const { open } = useModal();
const { clearExam } = useExam(); const { cancelExam } = useExamStore();
const [totalSeconds, setTotalSeconds] = useState( const { stopTimer, timeRemaining } = useTimerStore();
examDuration ? parseInt(examDuration) * 60 : 0 const { user } = useAuthStore();
);
const { timeRemaining, stopTimer } = useTimer();
const [userData, setUserData] = useState<UserData>();
useEffect(() => {
if (!examDuration) return;
const timer = setInterval(() => {
setTotalSeconds((prev) => {
if (prev <= 0) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [examDuration]);
useEffect(() => {
async function fetchUser() {
try {
const token = await getToken();
if (!token) return;
const response = await fetch(`${API_URL}/me`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const fetchedUserData = await response.json();
setUserData(fetchedUserData);
}
} catch (error) {
console.error("Error fetching user data:", error);
}
}
if (displayUser) {
fetchUser();
}
}, [displayUser]);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
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?");
if (confirmed) { if (confirmed) {
if (stopTimer) { stopTimer();
stopTimer(); cancelExam();
} router.replace("/categories");
clearExam();
router.push("/unit");
} }
}; };
@ -94,66 +40,96 @@ const Header = ({
router.back(); router.back();
}; };
// format time from context
const hours = Math.floor(timeRemaining / 3600);
const minutes = Math.floor((timeRemaining % 3600) / 60);
const seconds = timeRemaining % 60;
return ( return (
<header className={styles.header}> <header className="bg-[#113768] h-[100px] w-full pt-7 px-6 flex items-center justify-between sticky top-0 z-50">
{displayUser && ( {displayUser && (
<div className={styles.profile}> <div className="flex items-center gap-3">
<Avatar className="bg-gray-200 w-10 h-10"> <Avatar className="bg-gray-200 w-10 h-10">
<AvatarFallback className=" text-lg"> <AvatarFallback className="flex items-center justify-center h-10 text-lg">
{userData?.name ? userData.name.charAt(0).toUpperCase() : ""} {user?.username ? (
user.username.charAt(0).toUpperCase()
) : (
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className={styles.text}> <span className="text-md font-bold text-white flex items-center gap-2">
Hello, {userData?.name ? userData.name.split(" ")[0] : ""} Hello, {user?.username ? user.username.split(" ")[0] : "User"}{" "}
<BsPatchCheckFill size={20} color="white" />
</span> </span>
</div> </div>
)} )}
{displayTabTitle && ( {displayTabTitle && (
<div className={styles.profile}> <div className="flex justify-between items-center w-full">
<button onClick={handleBackClick} className={styles.iconButton}> <div className="flex items-center gap-3">
<ChevronLeft size={24} color="white" /> <button
</button> onClick={handleBackClick}
<span className={styles.text}>{displayTabTitle}</span> className="bg-none border-none p-1"
>
<ChevronLeft size={24} color="white" />
</button>
<span className="text-md font-bold text-white">
{displayTabTitle}
</span>
</div>
</div> </div>
)} )}
{displaySubject && ( {displaySubject && (
<div className={styles.profile}> <div className="flex items-center gap-3">
<span className={styles.text}>{displaySubject}</span> <span className="text-md font-bold text-white">{displaySubject}</span>
</div> </div>
)} )}
{examDuration && ( {/* Exam timer header */}
<div className={styles.examHeader}> {timeRemaining > 0 && (
<button onClick={showExitDialog} className={styles.iconButton}> <div className="flex justify-between w-full items-center">
<button
onClick={showExitDialog}
className="bg-none border-none cursor-pointer p-1 flex items-center justify-center"
>
<ChevronLeft size={30} color="white" /> <ChevronLeft size={30} color="white" />
</button> </button>
<div className={styles.timer}> <div className="w-40 h-14 bg-white flex justify-around items-center rounded-2xl ">
<div className={styles.timeUnit}> <div className="flex flex-col items-center w-full">
<span className={styles.timeValue}> <span className="font-medium text-md text-[#082E5E]">
{String(hours).padStart(2, "0")} {String(hours).padStart(2, "0")}
</span> </span>
<span className={styles.timeLabel}>Hrs</span> <span className="font-medium text-[12px] text-[#082E5E]">
Hrs
</span>
</div> </div>
<div className={styles.timeUnit}> <div className="flex flex-col items-center w-full">
<span className={styles.timeValue}> <span className="font-medium text-md text-[#082E5E]">
{String(minutes).padStart(2, "0")} {String(minutes).padStart(2, "0")}
</span> </span>
<span className={styles.timeLabel}>Mins</span> <span className="font-medium text-[12px] text-[#082E5E]">
Mins
</span>
</div> </div>
<div className={styles.timeUnit} style={{ borderRight: "none" }}> <div
<span className={styles.timeValue}> className="flex flex-col items-center w-full"
style={{ borderRight: "none" }}
>
<span className="font-medium text-md text-[#082E5E]">
{String(seconds).padStart(2, "0")} {String(seconds).padStart(2, "0")}
</span> </span>
<span className={styles.timeLabel}>Secs</span> <span className="font-medium text-[12px] text-[#082E5E]">
Secs
</span>
</div> </div>
</div> </div>
<button <button
onClick={open} onClick={open}
className={`${styles.iconButton} ${styles.disabled}`} className="bg-none border-none cursor-pointer p-1 flex items-center justify-center"
> >
<Layers size={30} color="white" /> <Layers size={30} color="white" />
</button> </button>

View File

@ -1,14 +1,6 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { UserData } from "@/types/auth";
interface UserData {
name: string;
institution: string;
sscRoll: string;
hscRoll: string;
email: string;
phone: string;
}
interface ProfileManagerProps { interface ProfileManagerProps {
userData: UserData | undefined; userData: UserData | undefined;
@ -23,80 +15,53 @@ export default function ProfileManager({
}: ProfileManagerProps) { }: ProfileManagerProps) {
if (!userData) return null; if (!userData) return null;
const handleChange = (field: keyof UserData, value: string) => { const handleChange = (field: keyof UserData, value: string | number) => {
setUserData((prev) => (prev ? { ...prev, [field]: value } : prev)); setUserData((prev) => (prev ? { ...prev, [field]: value } : prev));
}; };
return ( return (
<div className="mx-auto"> <div className="mx-auto">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> {/* Full Name */}
<Label htmlFor="name" className="text-sm font-semibold text-gray-700"> <h1 className="text-xl font-semibold tracking-tight">
Name Personal Information
</Label> </h1>
<Input
id="name"
type="text"
value={userData.name}
onChange={(e) => handleChange("name", e.target.value)}
className="bg-gray-50 py-6"
readOnly={!edit}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="institution" htmlFor="full_name"
className="text-sm font-semibold text-gray-700" className="text-sm font-semibold text-gray-700"
> >
Institution Full Name
</Label> </Label>
<Input <Input
id="institution" id="full_name"
type="text" type="text"
value={userData.institution} value={userData.full_name}
onChange={(e) => handleChange("institution", e.target.value)} onChange={(e) => handleChange("full_name", e.target.value)}
className="bg-gray-50 py-6"
readOnly={!edit}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="username"
className="text-sm font-semibold text-gray-700"
>
Username
</Label>
<Input
id="username"
type="text"
value={userData.username}
onChange={(e) => handleChange("username", e.target.value)}
className="bg-gray-50 py-6" className="bg-gray-50 py-6"
readOnly={!edit} readOnly={!edit}
/> />
</div> </div>
<div className="flex gap-4"> {/* SSC & HSC Rolls */}
<div className="space-y-2 w-full">
<Label
htmlFor="sscRoll"
className="text-sm font-semibold text-gray-700"
>
SSC Roll
</Label>
<Input
id="sscRoll"
type="text"
value={userData.sscRoll}
onChange={(e) => handleChange("sscRoll", e.target.value)}
className="bg-gray-50 py-6"
readOnly={!edit}
/>
</div>
<div className="space-y-2 w-full">
<Label
htmlFor="hscRoll"
className="text-sm font-semibold text-gray-700"
>
HSC Roll
</Label>
<Input
id="hscRoll"
type="text"
value={userData.hscRoll}
onChange={(e) => handleChange("hscRoll", e.target.value)}
className="bg-gray-50 py-6"
readOnly={!edit}
/>
</div>
</div>
{/* Email */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="email" htmlFor="email"
@ -114,22 +79,128 @@ export default function ProfileManager({
/> />
</div> </div>
{/* Phone */}
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="phone" htmlFor="phone_number"
className="text-sm font-semibold text-gray-700" className="text-sm font-semibold text-gray-700"
> >
Phone Phone
</Label> </Label>
<Input <Input
id="phone" id="phone_number"
type="tel" type="tel"
value={userData.phone} value={userData.phone_number}
onChange={(e) => handleChange("phone", e.target.value)} onChange={(e) => handleChange("phone_number", e.target.value)}
className="bg-gray-50 py-6" className="bg-gray-50 py-6"
readOnly={!edit} readOnly={!edit}
/> />
</div> </div>
<h1 className="text-xl tracking-tight font-semibold">
Educational Background
</h1>
<div className="space-y-2">
<Label
htmlFor="preparation_unit"
className="text-sm font-semibold text-gray-700"
>
Unit
</Label>
<Input
id="preparation_unit"
type="text"
value={userData.preparation_unit}
onChange={(e) => handleChange("preparation_unit", e.target.value)}
className="bg-gray-50 py-6"
readOnly={!edit}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="college"
className="text-sm font-semibold text-gray-700"
>
College
</Label>
<Input
id="college"
type="text"
value={userData.college}
onChange={(e) => handleChange("college", e.target.value)}
className="bg-gray-50 py-6"
readOnly={!edit}
/>
</div>
<div className="flex gap-4">
<div className="space-y-2 w-full">
<Label
htmlFor="ssc_roll"
className="text-sm font-semibold text-gray-700"
>
SSC Roll
</Label>
<Input
id="ssc_roll"
type="number"
value={userData.ssc_roll}
onChange={(e) => handleChange("ssc_roll", Number(e.target.value))}
className="bg-gray-50 py-6"
readOnly={!edit}
/>
</div>
<div className="space-y-2 w-full">
<Label
htmlFor="ssc_board"
className="text-sm font-semibold text-gray-700"
>
SSC Board
</Label>
<Input
id="ssc_board"
type="text"
value={userData.ssc_board}
onChange={(e) => handleChange("ssc_board", e.target.value)}
className="bg-gray-50 py-6"
readOnly={!edit}
/>
</div>
</div>
<div className="flex gap-4">
<div className="space-y-2 w-full">
<Label
htmlFor="hsc_roll"
className="text-sm font-semibold text-gray-700"
>
HSC Roll
</Label>
<Input
id="hsc_roll"
type="number"
value={userData.hsc_roll}
onChange={(e) => handleChange("hsc_roll", Number(e.target.value))}
className="bg-gray-50 py-6"
readOnly={!edit}
/>
</div>
<div className="space-y-2 w-full">
<Label
htmlFor="hsc_board"
className="text-sm font-semibold text-gray-700"
>
HSC Board
</Label>
<Input
id="hsc_board"
type="text"
value={userData.hsc_board}
onChange={(e) => handleChange("hsc_board", e.target.value)}
className="bg-gray-50 py-6"
readOnly={!edit}
/>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,130 +1,81 @@
import { Question } from "@/types/exam"; "use client";
import { BookmarkCheck, Bookmark } from "lucide-react";
import React, { useState } from "react";
import { Badge } from "./ui/badge";
interface ResultItemProps { import React from "react";
mode: "result"; import { Question, Answer } from "@/types/exam";
import { Bookmark } from "lucide-react";
interface QuestionItemProps {
question: Question; question: Question;
selectedAnswer: string | undefined; index: number;
selectedAnswer: Answer;
onSelect: (answer: Answer) => void;
userAnswer?: Answer;
correctAnswer?: Answer;
showResults?: boolean;
} }
interface ExamItemProps { const letters = ["A", "B", "C", "D"]; // extend if needed
mode: "exam";
question: Question;
selectedAnswer?: string;
handleSelect: (questionId: number, option: string) => void;
}
type QuestionItemProps = ResultItemProps | ExamItemProps;
const QuestionItem = (props: QuestionItemProps) => {
const [bookmark, setBookmark] = useState(false);
const { question, selectedAnswer } = props;
const isExam = props.mode === "exam";
const QuestionItem: React.FC<QuestionItemProps> = ({
question,
index,
selectedAnswer,
onSelect,
userAnswer,
correctAnswer,
showResults = false,
}) => {
return ( return (
<div className="border-[0.5px] border-[#8abdff]/60 rounded-2xl p-4 flex flex-col"> <div className="border border-blue-100 p-6 bg-slate-100 rounded-3xl mb-6">
<h3 className="text-xl font-semibold "> <p className="text-lg font-semibold mb-3">
{question.id}. {question.question} {index + 1}. {question.question}
</h3> </p>
{isExam && ( {!showResults && (
<div className="flex justify-between items-center mb-4"> <div className="w-full flex justify-between">
<div></div> <div></div>
<button onClick={() => setBookmark(!bookmark)}> <Bookmark size={24} />
{bookmark ? (
<BookmarkCheck size={25} color="#113768" />
) : (
<Bookmark size={25} color="#113768" />
)}
</button>
</div> </div>
)} )}
{isExam ? ( <div className="flex flex-col gap-3">
<div className="flex flex-col gap-4 items-start"> {question.options.map((opt, optIdx) => {
{Object.entries(question.options).map(([key, value]) => { const isSelected = selectedAnswer === optIdx;
const isSelected = selectedAnswer === key;
return ( // ✅ logic for coloring
let btnClasses = "bg-gray-100 text-gray-900 border-gray-400";
if (isSelected) {
btnClasses = "bg-blue-600 text-white border-blue-600";
}
if (showResults && correctAnswer !== undefined) {
if (userAnswer === optIdx && userAnswer !== correctAnswer) {
btnClasses = "bg-red-500 text-white border-red-600"; // wrong
}
if (correctAnswer === optIdx) {
btnClasses = "bg-green-500 text-white border-green-600"; // correct
}
}
return (
<div key={optIdx} className="flex items-center gap-3">
<button <button
key={key} onClick={() => {
className="flex items-center gap-3" if (showResults) return; // disable selection in results mode
onClick={ onSelect(optIdx); // always a number
isExam }}
? () => props.handleSelect(question.id, key) className={`w-7 h-7 rounded-full border font-bold
: undefined flex items-center justify-center
} ${btnClasses}
disabled={!isExam} transition-colors`}
> >
<span {letters[optIdx]}
className={`flex items-center rounded-full border px-1.5 ${
isSelected ? "text-white bg-[#113768] border-[#113768]" : ""
}`}
>
{key.toUpperCase()}
</span>
<span className="option-description">{value}</span>
</button> </button>
); <span className="text-gray-900">{opt}</span>
})} </div>
</div> );
) : ( })}
<div className="flex flex-col gap-3"> </div>
<div className="flex justify-between items-center">
<div></div>
{!selectedAnswer ? (
<Badge className="bg-yellow-500" variant="destructive">
Skipped
</Badge>
) : selectedAnswer.answer === question.correctAnswer ? (
<Badge className="bg-green-500 text-white" variant="default">
Correct
</Badge>
) : (
<Badge className="bg-red-500 text-white" variant="default">
Incorrect
</Badge>
)}
</div>
<div className="flex flex-col gap-4 items-start">
{Object.entries(question.options).map(([key, value]) => {
const isCorrect = key === question.correctAnswer;
const isSelected = key === selectedAnswer?.answer;
let optionStyle =
"px-2 py-1 flex items-center rounded-full border font-medium text-sm";
if (isCorrect) {
optionStyle += " bg-green-600 text-white border-green-600";
}
if (isSelected && !isCorrect) {
optionStyle += " bg-red-600 text-white border-red-600";
}
if (!isCorrect && !isSelected) {
optionStyle += " border-gray-300 text-gray-700";
}
return (
<div key={key} className="flex items-center gap-3">
<span className={optionStyle}>{key.toUpperCase()}</span>
<span className="option-description">{value}</span>
</div>
);
})}
</div>
<div className="h-[0.5px] border-[0.5px] border-dashed border-black/20"></div>
<div className="flex flex-col gap-2">
<h3 className="text-lg font-bold text-black/40">Solution:</h3>
<p className="text-lg">{question.solution}</p>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -1,13 +1,14 @@
import React, { useState, useRef, useEffect } from "react"; import React, {
import Link from "next/link"; useState,
import Image from "next/image"; useRef,
import styles from "../css/SlidingGallery.module.css"; useEffect,
useCallback,
UIEvent,
} from "react";
import { GalleryViews } from "@/types/gallery";
interface SlidingGalleryProps { interface SlidingGalleryProps {
views?: { views: GalleryViews[] | undefined;
id: string;
content: React.ReactNode;
}[];
className?: string; className?: string;
showPagination?: boolean; showPagination?: boolean;
autoScroll?: boolean; autoScroll?: boolean;
@ -17,7 +18,7 @@ interface SlidingGalleryProps {
} }
const SlidingGallery = ({ const SlidingGallery = ({
views = [], views,
className = "", className = "",
showPagination = true, showPagination = true,
autoScroll = false, autoScroll = false,
@ -25,15 +26,47 @@ const SlidingGallery = ({
onSlideChange = () => {}, onSlideChange = () => {},
height = "100vh", height = "100vh",
}: SlidingGalleryProps) => { }: SlidingGalleryProps) => {
const [activeIdx, setActiveIdx] = useState(0); const [activeIdx, setActiveIdx] = useState<number>(0);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const scrollRef = useRef(null);
const galleryRef = useRef(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const autoScrollRef = useRef(null); const galleryRef = useRef<HTMLDivElement | null>(null);
const autoScrollRef = useRef<NodeJS.Timeout | null>(null);
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
handleUserInteraction();
const scrollLeft = event.currentTarget.scrollLeft;
const slideWidth = dimensions.width;
const index = Math.round(scrollLeft / slideWidth);
if (index !== activeIdx) {
setActiveIdx(index);
onSlideChange(index);
}
};
const goToSlide = useCallback(
(index: number) => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
left: index * dimensions.width,
behavior: "smooth",
});
}
},
[dimensions.width]
);
const handleDotClick = (index: number) => {
handleUserInteraction();
goToSlide(index);
setActiveIdx(index);
onSlideChange(index);
};
// Auto-scroll functionality // Auto-scroll functionality
useEffect(() => { useEffect(() => {
if (autoScroll && views.length > 1) { if (autoScroll && views && views.length > 1) {
autoScrollRef.current = setInterval(() => { autoScrollRef.current = setInterval(() => {
setActiveIdx((prevIdx) => { setActiveIdx((prevIdx) => {
const nextIdx = (prevIdx + 1) % views.length; const nextIdx = (prevIdx + 1) % views.length;
@ -48,15 +81,17 @@ const SlidingGallery = ({
} }
}; };
} }
}, [autoScroll, autoScrollInterval, views.length]); }, [autoScroll, autoScrollInterval, views?.length, goToSlide, views]);
// Clear auto-scroll on user interaction // Clear auto-scroll on user interaction
const handleUserInteraction = () => { const handleUserInteraction = () => {
if (autoScrollRef.current) { if (autoScrollRef.current) {
clearInterval(autoScrollRef.current); clearInterval(autoScrollRef.current);
autoScrollRef.current = null;
} }
}; };
// Update dimensions
useEffect(() => { useEffect(() => {
const updateDimensions = () => { const updateDimensions = () => {
if (galleryRef.current) { if (galleryRef.current) {
@ -67,18 +102,13 @@ const SlidingGallery = ({
} }
}; };
// Initial dimension update
updateDimensions(); updateDimensions();
// Add resize listener
window.addEventListener("resize", updateDimensions); window.addEventListener("resize", updateDimensions);
// Cleanup
return () => window.removeEventListener("resize", updateDimensions); return () => window.removeEventListener("resize", updateDimensions);
}, []); }, []);
// Recalculate index when dimension changes
useEffect(() => { useEffect(() => {
// Recalculate active index when dimensions change
if (scrollRef.current && dimensions.width > 0) { if (scrollRef.current && dimensions.width > 0) {
const scrollLeft = scrollRef.current.scrollLeft; const scrollLeft = scrollRef.current.scrollLeft;
const slideWidth = dimensions.width; const slideWidth = dimensions.width;
@ -87,38 +117,12 @@ const SlidingGallery = ({
} }
}, [dimensions]); }, [dimensions]);
const handleScroll = (event: { target: { scrollLeft: any } }) => {
handleUserInteraction();
const scrollLeft = event.target.scrollLeft;
const slideWidth = dimensions.width;
const index = Math.round(scrollLeft / slideWidth);
if (index !== activeIdx) {
setActiveIdx(index);
onSlideChange(index);
}
};
const goToSlide = (index) => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
left: index * dimensions.width,
behavior: "smooth",
});
}
};
const handleDotClick = (index) => {
handleUserInteraction();
goToSlide(index);
setActiveIdx(index);
onSlideChange(index);
};
// Early return if no views
if (!views || views.length === 0) { if (!views || views.length === 0) {
return ( return (
<div className={`${styles.gallery} ${className}`}> <div
<div className={styles.emptyState}> className={`relative w-full h-screen overflow-hidden flex flex-col ${className}`}
>
<div className="flex-1 flex items-center justify-center text-slate-400 text-lg">
<p>No content to display</p> <p>No content to display</p>
</div> </div>
</div> </div>
@ -127,43 +131,49 @@ const SlidingGallery = ({
return ( return (
<div <div
className={`${styles.gallery} ${className}`} className={`relative w-full h-screen overflow-hidden flex flex-col ${className}`}
ref={galleryRef} ref={galleryRef}
style={{ height }} style={{ height }}
> >
<div <div
className={styles.scrollContainer} className="flex-1 flex overflow-x-auto"
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} onScroll={handleScroll}
style={{ style={{
width: "100%", width: "100%",
height: "100%", height: "100%",
overflowX: "scroll",
display: "flex",
scrollSnapType: "x mandatory",
scrollbarWidth: "none",
msOverflowStyle: "none",
}} }}
> >
{views.map((item) => ( {views.map((item) => (
<div <div
key={item.id} key={item.id}
className={styles.slide} className="min-w-full flex items-center justify-center px-2 box-border"
style={{ style={{
width: dimensions.width, width: dimensions.width,
height: "100%", height: "100%",
flexShrink: 0, flexShrink: 0,
scrollSnapAlign: "start",
}} }}
> >
{item.content} {item.content}
</div> </div>
))} ))}
</div> </div>
{showPagination && views.length > 1 && ( {showPagination && views.length > 1 && (
<div className={styles.pagination}> <div className="absolute bottom-[15px] left-1/2 -translate-x-1/2 flex gap-1.5 z-10">
{views.map((_, index) => ( {views.map((_, index) => (
<div <div
key={index} key={index}
className={`${styles.dot} ${ className={`w-2 h-2 rounded-full transition-all duration-300 ease-in ${
activeIdx === index ? styles.activeDot : styles.inactiveDot activeIdx === index ? "bg-[#113768]" : "bg-[#b1d3ff]"
}`} }`}
onClick={() => handleDotClick(index)} onClick={() => handleDotClick(index)}
style={{ cursor: "pointer" }}
/> />
))} ))}
</div> </div>

View File

@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-14 w-14 items-center justify-center border-y border-r text-lg font-bold shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@ -1,109 +0,0 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
interface AuthContextType {
token: string | null;
setToken: (token: string | null) => void;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Cookie utility functions
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) {
// Delete cookie by setting expiration to past date
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`;
}
};
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [token, setTokenState] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const pathname = usePathname();
// Custom setToken function that also updates cookies
const setToken = (newToken: string | null) => {
setTokenState(newToken);
setCookie("authToken", newToken);
};
// On app load, check if there's a token in cookies
useEffect(() => {
const initializeAuth = () => {
const storedToken = getCookie("authToken");
console.log("Current pathname:", pathname);
console.log("Stored token:", storedToken);
if (storedToken) {
setTokenState(storedToken);
if (
pathname === "/" ||
pathname === "/login" ||
pathname === "/register"
) {
console.log("Redirecting to /home");
router.replace("/home");
}
} else {
const publicPages = ["/", "/login", "/register"];
if (!publicPages.includes(pathname)) {
router.replace("/");
}
}
setIsLoading(false);
};
initializeAuth();
}, [pathname, router]);
// Function to log out
const logout = () => {
setTokenState(null);
setCookie("authToken", null); // Remove token from cookies
router.replace("/login"); // Redirect to login screen
};
return (
<AuthContext.Provider value={{ token, setToken, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
};
// Hook to use the AuthContext
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

View File

@ -1,276 +0,0 @@
"use client";
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from "react";
import { useRouter } from "next/navigation";
import { Exam, ExamAnswer, ExamAttempt, ExamContextType } from "@/types/exam";
import { getFromStorage, removeFromStorage, setToStorage } from "@/lib/utils";
const ExamContext = createContext<ExamContextType | undefined>(undefined);
const STORAGE_KEYS = {
CURRENT_EXAM: "current-exam",
CURRENT_ATTEMPT: "current-attempt",
} as const;
export const ExamProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const router = useRouter();
const [currentExam, setCurrentExamState] = useState<Exam | null>(null);
const [currentAttempt, setCurrentAttemptState] = useState<ExamAttempt | null>(
null
);
const [isHydrated, setIsHydrated] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
// Hydrate from session storage on mount
useEffect(() => {
const savedExam = getFromStorage<Exam>(STORAGE_KEYS.CURRENT_EXAM);
const savedAttempt = getFromStorage<ExamAttempt>(
STORAGE_KEYS.CURRENT_ATTEMPT
);
if (savedExam) {
setCurrentExamState(savedExam);
}
if (savedAttempt) {
// Convert date strings back to Date objects
const hydratedAttempt = {
...savedAttempt,
startTime: new Date(savedAttempt.startTime),
endTime: savedAttempt.endTime
? new Date(savedAttempt.endTime)
: undefined,
answers: savedAttempt.answers.map((answer) => ({
...answer,
timestamp: new Date(answer.timestamp),
})),
};
setCurrentAttemptState(hydratedAttempt);
}
setIsHydrated(true);
}, []);
// Persist to session storage whenever state changes
useEffect(() => {
if (!isHydrated) return;
if (currentExam) {
setToStorage(STORAGE_KEYS.CURRENT_EXAM, currentExam);
} else {
removeFromStorage(STORAGE_KEYS.CURRENT_EXAM);
}
}, [currentExam, isHydrated]);
useEffect(() => {
if (!isHydrated) return;
if (currentAttempt) {
setToStorage(STORAGE_KEYS.CURRENT_ATTEMPT, currentAttempt);
} else {
removeFromStorage(STORAGE_KEYS.CURRENT_ATTEMPT);
}
}, [currentAttempt, isHydrated]);
const setCurrentExam = (exam: Exam) => {
setCurrentExamState(exam);
setCurrentAttemptState(null);
};
const startExam = (exam?: Exam) => {
const examToUse = exam || currentExam;
if (!examToUse) {
console.warn("No exam selected, redirecting to /unit");
router.push("/unit");
return;
}
const attempt: ExamAttempt = {
examId: examToUse.id,
exam: examToUse,
answers: [],
startTime: new Date(),
totalQuestions: 0,
};
setCurrentAttemptState(attempt);
setIsInitialized(true);
};
const setAnswer = (questionId: string, answer: any) => {
if (!currentAttempt) {
console.warn("No exam attempt started, redirecting to /unit");
router.push("/unit");
return;
}
setCurrentAttemptState((prev) => {
if (!prev) return null;
const existingAnswerIndex = prev.answers.findIndex(
(a) => a.questionId === questionId
);
const newAnswer: ExamAnswer = {
questionId,
answer,
timestamp: new Date(),
};
let newAnswers: ExamAnswer[];
if (existingAnswerIndex >= 0) {
// Update existing answer
newAnswers = [...prev.answers];
newAnswers[existingAnswerIndex] = newAnswer;
} else {
// Add new answer
newAnswers = [...prev.answers, newAnswer];
}
return {
...prev,
answers: newAnswers,
};
});
};
const setApiResponse = (response: any) => {
if (!currentAttempt) {
console.warn("No exam attempt started, redirecting to /unit");
router.push("/unit");
return;
}
setCurrentAttemptState((prev) => {
if (!prev) return null;
return {
...prev,
apiResponse: response,
};
});
};
const submitExam = (): ExamAttempt | null => {
if (!currentAttempt) {
console.warn("No exam attempt to submit, redirecting to /unit");
router.push("/unit");
return null;
}
// Calculate score (simple example - you can customize this)
const attemptQuestions = currentAttempt.exam.questions;
const totalQuestions = attemptQuestions.length;
const answeredQuestions = currentAttempt.answers.filter(
(a) => a.questionId !== "__api_response__"
).length;
const score = Math.round((answeredQuestions / totalQuestions) * 100);
const completedAttempt: ExamAttempt = {
...currentAttempt,
endTime: new Date(),
score,
totalQuestions,
passed: currentAttempt.exam.passingScore
? score >= currentAttempt.exam.passingScore
: undefined,
};
setCurrentAttemptState(completedAttempt);
return completedAttempt;
};
const clearExam = (): void => {
setCurrentExamState(null);
setCurrentAttemptState(null);
};
const getAnswer = (questionId: string): any => {
if (!currentAttempt) return undefined;
const answer = currentAttempt.answers.find(
(a) => a.questionId === questionId
);
return answer?.answer;
};
const getApiResponse = (): any => {
return currentAttempt?.apiResponse;
};
const getProgress = (): number => {
if (!currentAttempt || !currentAttempt.exam) return 0;
const totalQuestions = currentAttempt.exam.questions.length;
const answeredQuestions = currentAttempt.answers.filter(
(a) => a.questionId !== "__api_response__"
).length;
return totalQuestions > 0 ? (answeredQuestions / totalQuestions) * 100 : 0;
};
const isExamStarted = () => !!currentExam && !!currentAttempt;
const isExamCompleted = (): boolean => {
if (!isHydrated) return false; // wait for hydration
return currentAttempt !== null && currentAttempt.endTime !== undefined;
};
const contextValue: ExamContextType = {
currentExam,
currentAttempt,
setCurrentExam,
startExam,
setAnswer,
submitExam,
clearExam,
setApiResponse,
getAnswer,
getProgress,
isExamStarted,
isExamCompleted,
getApiResponse,
isHydrated,
isInitialized,
};
return (
<ExamContext.Provider value={contextValue}>{children}</ExamContext.Provider>
);
};
export const useExam = (): ExamContextType => {
const context = useContext(ExamContext);
if (context === undefined) {
throw new Error("useExam must be used within an ExamProvider");
}
return context;
};
// Hook for exam results (only when exam is completed) - now returns null instead of throwing
export const useExamResults = (): ExamAttempt | null => {
const { currentAttempt, isExamCompleted, isHydrated } = useExam();
// Wait for hydration before making decisions
if (!isHydrated) {
return null;
}
// If no completed exam is found, return null (let component handle redirect)
if (!isExamCompleted() || !currentAttempt) {
return null;
}
return currentAttempt;
};

View File

@ -1,24 +1,41 @@
"use client"; "use client";
import { createContext, useContext, useState } from "react"; import { createContext, useContext, useState } from "react";
const ModalContext = createContext(null); // Define the context type
interface ModalContextType {
isOpen: boolean;
open: () => void;
close: () => void;
toggle: () => void;
}
export function ModalProvider({ children }) { // Create context with default values (no null)
const ModalContext = createContext<ModalContextType>({
isOpen: false,
open: () => {},
close: () => {},
toggle: () => {},
});
export function ModalProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true); const open = () => setIsOpen(true);
const close = () => setIsOpen(false); const close = () => setIsOpen(false);
const toggle = () => setIsOpen((prev) => !prev); const toggle = () => setIsOpen((prev) => !prev);
const value: ModalContextType = {
isOpen,
open,
close,
toggle,
};
return ( return (
<ModalContext.Provider value={{ isOpen, open, close, toggle }}> <ModalContext.Provider value={value}>{children}</ModalContext.Provider>
{children}
</ModalContext.Provider>
); );
} }
export function useModal() { export function useModal(): ModalContextType {
const ctx = useContext(ModalContext); return useContext(ModalContext);
if (!ctx) throw new Error("useModal must be inside <ModalProvider>");
return ctx;
} }

View File

@ -1,70 +0,0 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
// Define the context type
interface TimerContextType {
timeRemaining: number;
resetTimer: (duration: number) => void;
stopTimer: () => void;
setInitialTime: (duration: number) => void; // New function to set the initial time
}
// Create the context with a default value of `undefined`
const TimerContext = createContext<TimerContextType | undefined>(undefined);
// Provider Component
export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [timeRemaining, setTimeRemaining] = useState<number>(0); // Default is 0
let timer: NodeJS.Timeout;
useEffect(() => {
if (timeRemaining > 0) {
timer = setInterval(() => {
setTimeRemaining((prev) => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
}
return () => {
clearInterval(timer); // Cleanup timer on unmount
};
}, [timeRemaining]);
const resetTimer = (duration: number) => {
clearInterval(timer);
setTimeRemaining(duration);
};
const stopTimer = () => {
clearInterval(timer);
};
const setInitialTime = (duration: number) => {
setTimeRemaining(duration);
};
return (
<TimerContext.Provider
value={{ timeRemaining, resetTimer, stopTimer, setInitialTime }}
>
{children}
</TimerContext.Provider>
);
};
// Hook to use the TimerContext
export const useTimer = (): TimerContextType => {
const context = useContext(TimerContext);
if (!context) {
throw new Error("useTimer must be used within a TimerProvider");
}
return context;
};

View File

@ -1,167 +0,0 @@
/* SlidingGallery.module.css */
.gallery {
position: relative;
width: 100%;
height: 100vh; /* Default height, can be overridden by props */
overflow: hidden;
display: flex;
flex-direction: column;
}
.emptyState {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 1.2rem;
}
.scrollContainer {
flex: 1;
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.scrollContainer::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.slide {
min-width: 100%;
scroll-snap-align: start;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
}
.link {
width: 100%;
height: 100%;
display: block;
text-decoration: none;
color: inherit;
}
.facebook {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 40px;
box-sizing: border-box;
background: linear-gradient(135deg, #1877f2 0%, #42a5f5 100%);
border-radius: 20px;
color: white;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
}
.facebook:hover {
transform: translateY(-5px);
}
.textView {
flex: 1;
padding-right: 20px;
}
.facebookOne {
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 700;
margin: 0 0 16px 0;
line-height: 1.2;
}
.facebookTwo {
font-size: clamp(1rem, 2.5vw, 1.25rem);
margin: 0;
opacity: 0.9;
line-height: 1.4;
}
.logoView {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.logoView img {
width: clamp(120px, 15vw, 120px);
height: clamp(120px, 15vw, 120px);
object-fit: contain;
}
.pagination {
position: absolute;
bottom: 15px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
z-index: 10;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
transition: all 0.3s ease;
cursor: pointer;
}
.activeDot {
background-color: #113768;
}
.inactiveDot {
background-color: #b1d3ff;
}
.inactiveDot:hover {
background-color: rgba(255, 255, 255, 0.8);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.gallery {
height: 70vh; /* Adjust for mobile */
}
.facebook {
flex-direction: column;
text-align: center;
padding: 30px 20px;
}
.textView {
padding-right: 0;
margin-bottom: 20px;
}
.slide {
padding: 15px;
}
}
@media (max-width: 480px) {
.gallery {
height: 60vh;
}
.facebook {
padding: 20px 15px;
}
.slide {
padding: 10px;
}
}

View File

@ -11,6 +11,15 @@ const compat = new FlatCompat({
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
// Disable the no-explicit-any rule
"@typescript-eslint/no-explicit-any": "off",
// Alternative: Make it a warning instead of error
// "@typescript-eslint/no-explicit-any": "warn",
},
},
]; ];
export default eslintConfig; export default eslintConfig;

41
hooks/useNavGuard.ts Normal file
View File

@ -0,0 +1,41 @@
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useExamStore } from "@/stores/examStore";
import { useTimerStore } from "@/stores/timerStore";
export function useNavGuard(type: string) {
const { status, setStatus, cancelExam } = useExamStore();
const router = useRouter();
const { stopTimer } = useTimerStore();
// Guard page render: always redirect if status invalid
useEffect(() => {
if (status !== "in-progress") {
router.replace(`/categories/${type}s`);
}
}, [status, router, type]);
// Confirm before leaving page / tab close
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (status === "in-progress") {
e.preventDefault();
e.returnValue = ""; // shows native browser dialog
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [status]);
// Call this to quit exam manually
const showExitDialog = () => {
if (window.confirm("Are you sure you want to quit the exam?")) {
setStatus("finished");
stopTimer();
cancelExam();
router.replace(`/categories/${type}s`);
}
};
return { showExitDialog };
}

View File

@ -1,21 +1,43 @@
export const API_URL = "https://examjam-api.pptx704.com"; import { LoginForm, RegisterForm } from "@/types/auth";
// Cookie utility functions export const API_URL = process.env.NEXT_PUBLIC_EXAMJAM_API_URL;
const setCookie = (name, value, days = 7) => {
// Cookie utility function
const setCookie = (name: string, value: string | null, days: number = 7) => {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
if (value === null) { if (value === null) {
// Delete cookie by setting expiration to past date document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax;`; //SameSite=Strict; Secure in production
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict; Secure`;
} else { } else {
const expires = new Date(); const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value}; expires=${expires.toUTCString()}; path=/; SameSite=Strict; Secure`; document.cookie = `${name}=${encodeURIComponent(
value
)}; expires=${expires.toUTCString()}; path=/; SameSite=Lax;`; //SameSite=Strict; Secure in production
} }
}; };
export const login = async (form, setToken) => { export const getToken = async (): Promise<string | null> => {
const response = await fetch(`${API_URL}/auth/login`, { if (typeof window === "undefined") {
return null;
}
const match = document.cookie.match(/(?:^|;\s*)authToken=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
};
type SetTokenFn = (token: string) => void;
// Optional: Create a custom error type to carry extra data
interface APIError extends Error {
response?: any;
}
export const login = async (
form: LoginForm,
setToken: SetTokenFn
): Promise<void> => {
const response = await fetch(`${API_URL}/auth/login/`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -29,28 +51,15 @@ export const login = async (form, setToken) => {
throw new Error(data.message || "Login failed"); throw new Error(data.message || "Login failed");
} }
// Save the token to cookies instead of secure storage
setCookie("authToken", data.token); setCookie("authToken", data.token);
setToken(data.token); // Update the token in context setToken(data.token);
}; };
const handleError = (error) => { export const register = async (
// Check if error has a "detail" property form: RegisterForm,
if (error?.detail) { setToken: SetTokenFn
// Match the field causing the issue ): Promise<void> => {
const match = error.detail.match(/Key \((.*?)\)=\((.*?)\)/); const response = await fetch(`${API_URL}/auth/register/`, {
if (match) {
const field = match[1]; // The field name, e.g., "phone"
const value = match[2]; // The duplicate value, e.g., "0987654321"
return `The ${field} already exists. Please use a different value.`;
}
}
return "An unexpected error occurred. Please try again.";
};
export const register = async (form, setToken) => {
const response = await fetch(`${API_URL}/auth/register`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -58,43 +67,14 @@ export const register = async (form, setToken) => {
body: JSON.stringify(form), body: JSON.stringify(form),
}); });
const data = await response.json(); // Parse the response JSON const data = await response.json();
if (!response.ok) { if (!response.ok) {
// Instead of throwing a string, include full error data for debugging const error: APIError = new Error(data?.detail || "Registration failed");
const error = new Error(data?.detail || "Registration failed"); error.response = data;
error.response = data; // Attach the full response for later use
throw error; throw error;
} }
// Save the token to cookies instead of secure storage
setCookie("authToken", data.token); setCookie("authToken", data.token);
setToken(data.token); // Update the token in context setToken(data.token);
};
// Additional utility function to get token from cookies (if needed elsewhere)
export const getTokenFromCookie = () => {
if (typeof document === "undefined") return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; authToken=`);
if (parts.length === 2) {
return parts.pop()?.split(";").shift() || null;
}
return null;
};
// Utility function to clear auth token (for logout)
export const clearAuthToken = () => {
setCookie("authToken", null);
};
export const getToken = async () => {
if (typeof window === "undefined") {
return null;
}
// Extract authToken from cookies
const match = document.cookie.match(/(?:^|;\s*)authToken=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
}; };

View File

@ -1,19 +1,10 @@
// lib/gallery-views.tsx // lib/gallery-views.tsx
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { GalleryViews } from "@/types/gallery";
import { ExamResult } from "@/types/exam";
interface ExamResults { export const getResultViews = (examResults: ExamResult | null) => [
score: number;
totalQuestions: number;
answers: string[];
}
interface LinkedViews {
id: string;
content: React.ReactNode;
}
export const getResultViews = (examResults: ExamResults | null) => [
{ {
id: 1, id: 1,
content: ( content: (
@ -32,7 +23,8 @@ export const getResultViews = (examResults: ExamResults | null) => [
<h2 className="text-6xl font-bold text-[#113678]"> <h2 className="text-6xl font-bold text-[#113678]">
{examResults {examResults
? ( ? (
(examResults.score / examResults.totalQuestions) * (examResults.correct_answers_count /
examResults.user_questions.length) *
100 100
).toFixed(1) ).toFixed(1)
: "0"} : "0"}
@ -61,8 +53,9 @@ export const getResultViews = (examResults: ExamResults | null) => [
<h2 className="text-6xl font-bold text-[#113678]"> <h2 className="text-6xl font-bold text-[#113678]">
{examResults {examResults
? ( ? (
((examResults.totalQuestions - examResults.score) / ((examResults.user_questions.length -
examResults.totalQuestions) * examResults.correct_answers_count) /
examResults.user_questions.length) *
100 100
).toFixed(1) ).toFixed(1)
: "0"} : "0"}
@ -91,7 +84,8 @@ export const getResultViews = (examResults: ExamResults | null) => [
<h2 className="text-6xl font-bold text-[#113678]"> <h2 className="text-6xl font-bold text-[#113678]">
{examResults {examResults
? ( ? (
(examResults.answers.length / examResults.totalQuestions) * (examResults.user_answers.length /
examResults.user_questions.length) *
100 100
).toFixed(1) ).toFixed(1)
: "0"} : "0"}
@ -104,15 +98,15 @@ export const getResultViews = (examResults: ExamResults | null) => [
}, },
]; ];
export const getLinkedViews = (): LinkedViews[] => [ export const getLinkedViews = (): GalleryViews[] => [
{ {
id: "1", id: 1,
content: ( content: (
<Link <Link
href="https://www.facebook.com/share/g/15jdqESvWV/?mibextid=wwXIfr" href="https://www.facebook.com/share/g/15jdqESvWV/?mibextid=wwXIfr"
className="w-full h-full block text-inherit box-border" className=" block"
> >
<div className="w-full h-full p-6 flex text-black bg-blue-50 rounded-4xl border-[0.5px] border-[#113768]/30"> <div className="w-full h-full p-6 flex text-black bg-blue-50 rounded-3xl border-[0.5px] border-[#113768]/30">
<div className=""> <div className="">
<h3 className="text-2xl text-[#113768] font-black"> <h3 className="text-2xl text-[#113768] font-black">
Meet, Share, and Learn! Meet, Share, and Learn!

View File

@ -1,7 +1,5 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {};
/* config options here */
};
export default nextConfig; export default nextConfig;

4059
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,22 +4,32 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
"build": "next build", "build": "next build ",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"build:export": "npx next build && npm run export"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^7.4.2",
"@capacitor/core": "^7.4.2",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"capacitor-secure-storage-plugin": "^0.11.0", "capacitor-secure-storage-plugin": "^0.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.523.0", "lucide-react": "^0.523.0",
"next": "15.3.2", "next": "15.3.2",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"tailwind-merge": "^3.3.1",
"uuid": "^11.1.0",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "^7.4.2",
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",

175
public/data/questions.json Normal file
View File

@ -0,0 +1,175 @@
{
"questions": [
{
"correct_answer": "Assets = Liabilities + Equity",
"difficulty": "Easy",
"explanation": "This is the basic accounting equation.",
"is_randomized": true,
"options": [
"Assets = Liabilities + Equity",
"Assets = Revenue - Expenses",
"Assets = Liabilities - Equity",
"Assets = Equity - Liabilities"
],
"question": "What is the basic accounting equation?",
"source_type": "Mock",
"subject_id": "f1c1d54a-0b29-44e2-8f95-ecf9f9e6d8b7",
"topic_id": "a1b2c3d4-e5f6-7890-abcd-1234567890ab",
"type": "Single"
},
{
"correct_answer": "Balance Sheet",
"difficulty": "Easy",
"explanation": "A balance sheet shows a company's assets, liabilities, and equity at a specific point in time.",
"is_randomized": true,
"options": [
"Balance Sheet",
"Income Statement",
"Cash Flow Statement",
"Statement of Retained Earnings"
],
"question": "Which financial statement shows a company's assets, liabilities, and equity?",
"source_type": "Mock",
"subject_id": "f1c1d54a-0b29-44e2-8f95-ecf9f9e6d8b7",
"topic_id": "b2c3d4e5-f678-9012-abcd-2345678901bc",
"type": "Single"
},
{
"correct_answer": "Revenue - Expenses",
"difficulty": "Easy",
"explanation": "Net income is calculated by subtracting expenses from revenue.",
"is_randomized": true,
"options": [
"Revenue - Expenses",
"Assets - Liabilities",
"Equity + Liabilities",
"Revenue + Expenses"
],
"question": "How is net income calculated?",
"source_type": "Mock",
"subject_id": "f1c1d54a-0b29-44e2-8f95-ecf9f9e6d8b7",
"topic_id": "c3d4e5f6-7890-1234-abcd-3456789012cd",
"type": "Single"
},
{
"correct_answer": "Accounts Receivable",
"difficulty": "Easy",
"explanation": "Accounts receivable represents money owed to a company by its customers.",
"is_randomized": true,
"options": [
"Accounts Receivable",
"Accounts Payable",
"Inventory",
"Prepaid Expenses"
],
"question": "Which account represents money owed to a company by its customers?",
"source_type": "Mock",
"subject_id": "f1c1d54a-0b29-44e2-8f95-ecf9f9e6d8b7",
"topic_id": "d4e5f678-9012-3456-abcd-4567890123de",
"type": "Single"
},
{
"correct_answer": "Depreciation",
"difficulty": "Medium",
"explanation": "Depreciation is the allocation of the cost of a tangible asset over its useful life.",
"is_randomized": true,
"options": [
"Depreciation",
"Amortization",
"Depletion",
"Appreciation"
],
"question": "What is the allocation of the cost of a tangible asset over its useful life called?",
"source_type": "Mock",
"subject_id": "f1c1d54a-0b29-44e2-8f95-ecf9f9e6d8b7",
"topic_id": "e5f67890-1234-5678-abcd-5678901234ef",
"type": "Single"
},
{
"correct_answer": "Accrual Basis",
"difficulty": "Medium",
"explanation": "Accrual basis accounting recognizes revenues and expenses when they are incurred, not when cash is exchanged.",
"is_randomized": true,
"options": [
"Accrual Basis",
"Cash Basis",
"Modified Cash Basis",
"Tax Basis"
],
"question": "Which accounting method recognizes revenues and expenses when they are incurred, regardless of when cash is exchanged?",
"source_type": "Mock",
"subject_id": "f1c1d54a-0b29-44e2-8f95-ecf9f9e6d8b7",
"topic_id": "f6789012-3456-7890-abcd-6789012345fa",
"type": "Single"
},
{
"correct_answer": "Liabilities",
"difficulty": "Easy",
"explanation": "Liabilities are obligations that a company owes to outside parties.",
"is_randomized": true,
"options": [
"Liabilities",
"Assets",
"Equity",
"Revenue"
],
"question": "What are obligations that a company owes to outside parties called?",
"source_type": "Mock",
"subject_id": "f1c1d54a-0b29-44e2-8f95-ecf9f9e6d8b7",
"topic_id": "a7890123-4567-8901-abcd-7890123456ab",
"type": "Single"
},
{
"correct_answer": "Double-entry",
"difficulty": "Medium",
"explanation": "Double-entry accounting means every transaction affects at least two accounts.",
"is_randomized": true,
"options": [
"Double-entry",
"Single-entry",
"Triple-entry",
"Quadruple-entry"
],
"question": "What is the accounting system called where every transaction affects at least two accounts?",
"source_type": "Mock",
"subject_id": "f1c1d54a-0b29-44e2-8f95-ecf9f9e6d8b7",
"topic_id": "b8901234-5678-9012-abcd-8901234567bc",
"type": "Single"
},
{
"correct_answer": "Owner's Equity",
"difficulty": "Easy",
"explanation": "Owner's equity represents the owner's claims to the assets of the business.",
"is_randomized": true,
"options": [
"Owner's Equity",
"Liabilities",
"Revenue",
"Expenses"
],
"question": "What is the owner's claim to the assets of a business called?",
"source_type": "Mock",
"subject_id": "f1c1d54a-0b29-44e2-8f95-ecf9f9e6d8b7",
"topic_id": "c9012345-6789-0123-abcd-9012345678cd",
"type": "Single"
},
{
"correct_answer": "Matching Principle",
"difficulty": "Medium",
"explanation": "The matching principle requires that expenses be matched with related revenues.",
"is_randomized": true,
"options": [
"Matching Principle",
"Revenue Recognition Principle",
"Cost Principle",
"Conservatism Principle"
],
"question": "Which accounting principle requires that expenses be matched with related revenues?",
"source_type": "Mock",
"subject_id": "f1c1d54a-0b29-44e2-8f95-ecf9f9e6d8b7",
"topic_id": "d0123456-7890-1234-abcd-0123456789de",
"type": "Single"
}
]
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

147
stores/authStore.ts Normal file
View File

@ -0,0 +1,147 @@
"use client";
import { create } from "zustand";
import { LoginForm, RegisterForm, 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 APIError extends Error {
response?: any;
}
interface AuthState {
token: string | null;
isLoading: boolean;
hydrated: boolean;
user: UserData | null;
error: string | null;
login: (form: LoginForm) => Promise<void>;
register: (form: RegisterForm) => Promise<void>;
setToken: (token: string | null) => void;
logout: () => void;
initializeAuth: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set, get) => ({
token: null,
isLoading: true,
hydrated: false,
error: null,
user: null,
setToken: (newToken) => {
set({ token: newToken });
setCookie("authToken", newToken);
},
login: async (form: LoginForm) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`${API_URL}/auth/login/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Login failed");
}
const token = data.token;
setCookie("authToken", token);
set({ token });
// Automatically fetch user info after login
const userRes = await fetch(`${API_URL}/me/profile/`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!userRes.ok) {
throw new Error("Failed to fetch user info after login");
}
const userData: UserData = await userRes.json();
set({ user: userData, isLoading: false });
} catch (err: any) {
console.error("Login error:", err);
set({
error: err?.message || "Login failed",
isLoading: false,
});
throw err;
}
},
register: async (form: RegisterForm) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`${API_URL}/auth/register/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await response.json();
if (!response.ok) {
const error: APIError = new Error(
data?.detail || "Registration failed"
);
error.response = data;
throw error;
}
setCookie("authToken", data.token);
set({ token: data.token, isLoading: false });
} catch (err: any) {
set({
error: err?.message || "Registration failed",
isLoading: false,
});
throw err;
}
},
logout: () => {
set({ token: null, user: null });
setCookie("authToken", null);
},
initializeAuth: async () => {
const storedToken = getCookie("authToken");
if (storedToken) {
set({ token: storedToken });
}
set({ isLoading: false, hydrated: true });
},
}));

106
stores/examStore.ts Normal file
View File

@ -0,0 +1,106 @@
"use client";
import { create } from "zustand";
import { Test, Answer } from "@/types/exam";
import { API_URL, getToken } from "@/lib/auth";
import { ExamResult } from "@/types/exam";
type ExamStatus = "not-started" | "in-progress" | "finished";
interface ExamState {
test: Test | null;
answers: Answer[];
result: ExamResult | null;
status: ExamStatus;
setStatus: (status: ExamStatus) => void;
startExam: (testType: string, testId: string) => Promise<Test | null>;
setAnswer: (questionIndex: number, answer: Answer) => void;
submitExam: (testType: string) => Promise<ExamResult | null>;
cancelExam: () => void;
clearResult: () => void;
}
export const useExamStore = create<ExamState>((set, get) => ({
test: null,
answers: [],
result: null,
status: "not-started",
setStatus: (status) => set({ status }),
// 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),
result: null, // clear old result
});
return data;
} catch (err) {
console.error("startExam error:", err);
return null;
}
},
// 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) throw new Error("No test to submit");
const token = await getToken();
const { test_id, attempt_id } = test.metadata;
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 }),
}
);
if (!res.ok) throw new Error("Failed to submit exam");
const result: ExamResult = await res.json();
// save result only
set({ result });
return result;
},
// cancel exam
cancelExam: () => {
set({ test: null, answers: [], result: null });
},
// clear result manually (e.g., when leaving results page)
clearResult: () => {
set({ result: null });
},
}));

48
stores/timerStore.ts Normal file
View 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 });
},
}));

35
types/auth.d.ts vendored
View File

@ -1,8 +1,33 @@
export interface UserData { export interface UserData {
name: string; user_id: string;
institution: string; username: string;
sscRoll: string; full_name: string;
hscRoll: string;
email: string; email: string;
phone: string; is_verified: boolean;
phone_number: string;
ssc_roll: number;
ssc_board: string;
hsc_roll: number;
hsc_board: string;
college: string;
preparation_unit: "Science" | "Humanities" | "Business" | string;
}
export interface RegisterForm {
full_name: string;
username: string;
email: string;
password: string;
phone_number: string;
ssc_roll: number;
ssc_board: string;
hsc_roll: number;
hsc_board: string;
college: string;
preparation_unit: "Science" | "Humanities" | "Business" | string;
}
export interface LoginForm {
identifier: string;
password: string;
} }

88
types/exam.d.ts vendored
View File

@ -1,57 +1,43 @@
export interface Question { export interface Metadata {
id: string; attempt_id: string;
text: string; test_id: string;
options?: Record<string, string>; type: string;
type: "multiple-choice" | "text" | "boolean" | undefined; total_possible_score: number;
correctAnswer: string | undefined; deduction?: string;
solution?: string | undefined; num_questions: number;
name: string;
start_time: string; // keep ISO string for consistency
time_limit_minutes?: number;
} }
export interface Exam { export type Question = {
id: string; question_id: string;
title: string; question: string;
description: string; options: string[];
type: "Single" | "Multiple";
};
export interface Test {
metadata: Metadata;
questions: Question[]; questions: Question[];
timeLimit?: number;
passingScore?: number;
} }
export interface ExamAnswer { export type Answer = number | null;
questionId: string; export type AnswersMap = Record<string, Answer>;
answer: any;
timestamp: Date; export interface ExamResult {
} user_id: string;
test_id: string;
export interface ExamAttempt { subject_id: string;
examId: string; topic_id: string;
exam: Exam; test_type: string;
answers: ExamAnswer[]; attempt_id: string;
startTime: Date; start_time: string;
endTime?: Date; end_time: string;
score?: number; user_questions: Question[];
passed?: boolean; user_answers: (number | null)[];
apiResponse?: any; correct_answers: number[];
totalQuestions: number; correct_answers_count: number;
} wrong_answers_count: number;
skipped_questions_count: number;
export interface ExamContextType {
currentExam: Exam | null;
currentAttempt: ExamAttempt | null;
isHydrated: boolean;
isInitialized: boolean;
// Actions
setCurrentExam: (exam: Exam) => void;
startExam: () => void;
setAnswer: (questionId: string, answer: any) => void;
submitExam: () => ExamAttempt;
clearExam: () => void;
setApiResponse: (response: any) => void;
// Getters
getAnswer: (questionId: string) => any;
getProgress: () => number;
isExamStarted: () => boolean;
isExamCompleted: () => boolean;
getApiResponse: () => any;
} }

4
types/gallery.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export interface GalleryViews {
id: number;
content: React.JSX.Element;
}